mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d17a91ed | ||
|
|
2f15202a68 | ||
|
|
342a2b1ca4 | ||
|
|
75915cd2b5 | ||
|
|
d8db9b99a2 | ||
|
|
d8e1f0daa1 | ||
|
|
8a350d953c | ||
|
|
3fb3150ee2 | ||
|
|
7dbc0fc3bb | ||
|
|
fc4f8644eb | ||
|
|
dda6d55fbf | ||
|
|
e014759b40 | ||
|
|
2186c41c48 | ||
|
|
c30c182478 | ||
|
|
3080567964 | ||
|
|
f304541561 | ||
|
|
91224ada13 | ||
|
|
d5fbaeef81 | ||
|
|
3706018b72 | ||
|
|
62e616f635 | ||
|
|
9013d324c8 | ||
|
|
c1363ec8d0 | ||
|
|
7e09196f92 | ||
|
|
807043b4b0 | ||
|
|
972fe35935 | ||
|
|
16ee540f5d | ||
|
|
f541882d55 | ||
|
|
95bc156747 | ||
|
|
bf7422022f | ||
|
|
230e5b91f8 | ||
|
|
70dcf21e37 | ||
|
|
932a1fb30c | ||
|
|
44fe60b701 | ||
|
|
1c057ca9b9 | ||
|
|
9f793d1336 | ||
|
|
aa9295bea9 | ||
|
|
370eea4977 | ||
|
|
59e6819020 | ||
|
|
52b633f4e9 | ||
|
|
6b1e6ca1c9 | ||
|
|
6c112eccb7 | ||
|
|
f9e9effcdd | ||
|
|
b5d2d0fefa |
Vendored
+2
@@ -66,6 +66,7 @@ import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
@@ -178,6 +179,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
|
||||
@@ -58,6 +58,13 @@ crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveS
|
||||
batchSize: 100,
|
||||
});
|
||||
|
||||
crons.interval(
|
||||
"package-scan-backfill",
|
||||
{ minutes: 30 },
|
||||
internal.packages.backfillPackageReleaseScansInternal,
|
||||
{ batchSize: 100 },
|
||||
);
|
||||
|
||||
// Daily re-scan of all active skills at 3am UTC
|
||||
crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import {
|
||||
repointPackageLatestRelease,
|
||||
scheduleOwnerPublisherDigestSync,
|
||||
syncPackageSearchDigestForPackageId,
|
||||
syncPackageSearchDigestsForOwnerUserId,
|
||||
} from "./functions";
|
||||
@@ -501,3 +503,35 @@ describe("package digest sync", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher digest scheduling", () => {
|
||||
it("schedules package and skill digest sync in separate background mutations", async () => {
|
||||
const ctx = {
|
||||
scheduler: {
|
||||
runAfter: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
await scheduleOwnerPublisherDigestSync(ctx as never, "publishers:demo" as never);
|
||||
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
});
|
||||
|
||||
it("skips scheduling when the trigger context has no scheduler", async () => {
|
||||
await expect(
|
||||
scheduleOwnerPublisherDigestSync({} as never, "publishers:demo" as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+39
-2
@@ -1,6 +1,8 @@
|
||||
import { customCtx, customMutation } from "convex-helpers/server/customFunctions";
|
||||
import { Triggers } from "convex-helpers/server/triggers";
|
||||
import { v } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { DataModel, Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
mutation as rawMutation,
|
||||
@@ -30,6 +32,7 @@ function isMissingTableError(error: unknown, table: string) {
|
||||
}
|
||||
|
||||
type PackageDigestSyncCtx = Pick<MutationCtx, "db">;
|
||||
type OwnerPublisherDigestScheduleCtx = Pick<Partial<MutationCtx>, "scheduler">;
|
||||
type LatestPackageRelease = Pick<
|
||||
Doc<"packageReleases">,
|
||||
| "_id"
|
||||
@@ -233,6 +236,41 @@ export async function syncSkillSearchDigestsForOwnerPublisherId(
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleOwnerPublisherDigestSync(
|
||||
ctx: OwnerPublisherDigestScheduleCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!ownerPublisherId || !ctx.scheduler) return;
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
}
|
||||
|
||||
export const syncPackageSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncPackageSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
|
||||
},
|
||||
});
|
||||
|
||||
export const syncSkillSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncSkillSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
|
||||
},
|
||||
});
|
||||
|
||||
export async function repointPackageLatestRelease(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
packageId: Id<"packages"> | null | undefined,
|
||||
@@ -335,8 +373,7 @@ triggers.register("users", async (ctx, change) => {
|
||||
|
||||
triggers.register("publishers", async (ctx, change) => {
|
||||
const ownerPublisherId = change.operation === "delete" ? change.id : change.newDoc._id;
|
||||
await syncPackageSearchDigestsForOwnerPublisherId(ctx, ownerPublisherId);
|
||||
await syncSkillSearchDigestsForOwnerPublisherId(ctx, ownerPublisherId);
|
||||
await scheduleOwnerPublisherDigestSync(ctx, ownerPublisherId);
|
||||
});
|
||||
|
||||
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
listBundlePluginsV1Http,
|
||||
listCodePluginsV1Http,
|
||||
listPackagesV1Http,
|
||||
listPluginsV1Http,
|
||||
listSkillsV1Http,
|
||||
listSoulsV1Http,
|
||||
packagesGetRouterV1Http,
|
||||
pluginsGetRouterV1Http,
|
||||
publishSkillV1Http,
|
||||
publishPackageV1Http,
|
||||
publishSoulV1Http,
|
||||
@@ -74,6 +76,12 @@ http.route({
|
||||
handler: listPackagesV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.plugins,
|
||||
method: "GET",
|
||||
handler: listPluginsV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.codePlugins,
|
||||
method: "GET",
|
||||
@@ -98,6 +106,12 @@ http.route({
|
||||
handler: packagesGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.plugins}/`,
|
||||
method: "GET",
|
||||
handler: pluginsGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.skills,
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import { RATE_LIMITS } from "./lib/httpRateLimit";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
@@ -41,6 +42,10 @@ function hasSlugArgs(args: unknown): args is { slug: string } {
|
||||
return typeof value.slug === "string";
|
||||
}
|
||||
|
||||
function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === "function"
|
||||
@@ -2294,13 +2299,10 @@ describe("httpApiV1 handlers", () => {
|
||||
capabilityTag: "tools",
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -2601,6 +2603,112 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("packages version detail returns security scan fields for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args && !("version" in args)) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: { latest: "packageReleases:1" },
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:demo", handle: "demo" },
|
||||
};
|
||||
}
|
||||
if ("name" in args && "version" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
_id: "packageReleases:1",
|
||||
packageId: "packages:demo-plugin",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: 10,
|
||||
sha256: "file-sha",
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "clean",
|
||||
},
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: 1,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
summary: "Looks safe.",
|
||||
checkedAt: 1,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "1",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/versions/1.0.0"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
sha256hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
summary: "No issues",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats /packages/search without q as a package detail route", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
@@ -2699,13 +2807,10 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.download.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file uses read rate limiting", async () => {
|
||||
@@ -2762,13 +2867,66 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file resolves lowercase readme variants from the canonical request path", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "readme.md",
|
||||
size: 5,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/file?path=README.md"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("package download uses a package/ root without registry metadata", async () => {
|
||||
@@ -2905,7 +3063,7 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(await response.text()).toBe("Missing stored file: dist/index.js");
|
||||
});
|
||||
|
||||
it("blocks package downloads while VT scan is pending", async () => {
|
||||
it("allows package downloads while VT scan is pending", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
@@ -2933,19 +3091,86 @@ describe("httpApiV1 handlers", () => {
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
files: [],
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(['{"name":"demo-plugin"}']));
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:1");
|
||||
});
|
||||
|
||||
it("allows package downloads when verification is clean even without cached vtAnalysis", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
verification: { scanStatus: "clean" },
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
expect(await response.text()).toContain("pending a security scan");
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks package file access when release is malicious", async () => {
|
||||
@@ -3095,13 +3320,10 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: "user:users:1",
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: "user:users:1",
|
||||
limit: RATE_LIMITS.write.key,
|
||||
});
|
||||
expect(runAction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
listBundlePluginsV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import {
|
||||
@@ -28,7 +30,9 @@ import { usersListV1Handler, usersPostRouterV1Handler } from "./httpApiV1/usersV
|
||||
import { whoamiV1Handler } from "./httpApiV1/whoamiV1";
|
||||
|
||||
export const listPackagesV1Http = httpAction(listPackagesV1Handler);
|
||||
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
|
||||
export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
|
||||
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
|
||||
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
@@ -57,7 +61,9 @@ export const usersListV1Http = httpAction(usersListV1Handler);
|
||||
|
||||
export const __handlers = {
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listBundlePluginsV1Handler,
|
||||
|
||||
+136
-101
@@ -5,6 +5,7 @@ import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPackageDownloadSecurityBlock } from "../lib/packageSecurity";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { buildDeterministicPackageZip } from "../lib/skillZip";
|
||||
@@ -139,24 +140,7 @@ function toVisibleRelease(release: ReleaseLike | null) {
|
||||
}
|
||||
|
||||
function getReleaseSecurityBlock(release: ReleaseLike) {
|
||||
if (
|
||||
release.vtAnalysis?.status === "malicious" ||
|
||||
release.verification?.scanStatus === "malicious" ||
|
||||
release.staticScan?.status === "malicious"
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
const vtStatus = release.vtAnalysis?.status?.trim().toLowerCase();
|
||||
if (release.sha256hash && (!vtStatus || vtStatus === "pending")) {
|
||||
return {
|
||||
status: 423,
|
||||
message: "This package release is pending a security scan by VirusTotal. Please try again in a few minutes.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return getPackageDownloadSecurityBlock(release);
|
||||
}
|
||||
|
||||
async function resolvePackageTags(
|
||||
@@ -445,7 +429,12 @@ async function parseMultipartPackagePublish(ctx: ActionCtx, request: Request) {
|
||||
return parsePackagePublishBody({ ...payload, files });
|
||||
}
|
||||
|
||||
async function listPackages(ctx: ActionCtx, request: Request, family?: PackageListQueryArgs["family"]) {
|
||||
async function listPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
family?: PackageListQueryArgs["family"],
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -463,6 +452,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
(familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined);
|
||||
const includeSkills = options?.includeSkills ?? effectiveFamily === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
@@ -491,7 +481,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily) {
|
||||
if (!effectiveFamily && includeSkills) {
|
||||
const packageSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).packages);
|
||||
const skillSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).skills);
|
||||
const pageSize = limit;
|
||||
@@ -588,7 +578,11 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
}
|
||||
|
||||
export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request);
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: true });
|
||||
}
|
||||
|
||||
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: false });
|
||||
}
|
||||
|
||||
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -686,6 +680,23 @@ function resolveSkillFilePath(version: SkillVersionLike, requestedPath: string)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePackageFilePath(release: ReleaseLike, requestedPath: string) {
|
||||
const normalized = requestedPath.trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
if (isReadmeVariantPath(normalized)) {
|
||||
return (
|
||||
release.files.find((file) => isReadmeVariantPath(file.path)) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return (
|
||||
release.files.find((file) => file.path === normalized) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function getSkillDetailForRequest(ctx: ActionCtx, slug: string) {
|
||||
return (await runQueryRef(ctx, apiRefs.skills.getBySlug, { slug })) as
|
||||
| {
|
||||
@@ -725,96 +736,107 @@ async function getSkillVersionForRequest(
|
||||
})) as SkillVersionLike | null;
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
async function searchPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const includeSkills = options?.includeSkills ?? family === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
} else if (family || !includeSkills) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
} else if (family) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: true });
|
||||
}
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const packageName = segments[0] ?? "";
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
@@ -970,6 +992,10 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
compatibility: result.version.compatibility ?? null,
|
||||
capabilities: result.version.capabilities ?? null,
|
||||
verification: result.version.verification ?? null,
|
||||
sha256hash: result.version.sha256hash ?? null,
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
},
|
||||
}, 200, rate.headers);
|
||||
}
|
||||
@@ -1002,7 +1028,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
const file = release.files.find((entry) => entry.path === path);
|
||||
const file = resolvePackageFilePath(release, path);
|
||||
if (!file) return text("File not found", 404, rate.headers);
|
||||
if (!isTextFile(file.path, file.contentType)) {
|
||||
return text("Binary files are not served inline", 415, rate.headers);
|
||||
@@ -1065,6 +1091,15 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/plugins/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: false });
|
||||
}
|
||||
return text("Not found", 404);
|
||||
}
|
||||
|
||||
type PublicPackageDocLike = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { corsHeaders, mergeHeaders } from "./httpHeaders";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
export const RATE_LIMITS = {
|
||||
read: { ip: 120, key: 600 },
|
||||
write: { ip: 30, key: 120 },
|
||||
download: { ip: 20, key: 120 },
|
||||
read: { ip: 180, key: 900 },
|
||||
write: { ip: 45, key: 180 },
|
||||
download: { ip: 30, key: 180 },
|
||||
} as const;
|
||||
|
||||
type RateLimitResult = {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPackageDownloadSecurityBlock,
|
||||
isPackageBlockedFromPublic,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./packageSecurity";
|
||||
|
||||
describe("packageSecurity", () => {
|
||||
it("treats pending package scans as public", () => {
|
||||
expect(isPackageBlockedFromPublic("pending")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows package downloads while VT is pending", () => {
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still resolves sha256-only releases to pending", () => {
|
||||
expect(
|
||||
resolvePackageReleaseScanStatus({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("still blocks malicious package releases", () => {
|
||||
expect(isPackageBlockedFromPublic("malicious")).toBe(true);
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
vtAnalysis: { status: "malicious" },
|
||||
} as never),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
|
||||
export type PackageScanStatus = Doc<"packages">["scanStatus"];
|
||||
|
||||
type PackageReleaseSecurityLike = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"sha256hash" | "vtAnalysis" | "verification" | "staticScan"
|
||||
>;
|
||||
|
||||
export function normalizePackageScanStatus(status: string | null | undefined): PackageScanStatus {
|
||||
switch (status?.trim().toLowerCase()) {
|
||||
case "clean":
|
||||
case "suspicious":
|
||||
case "malicious":
|
||||
case "pending":
|
||||
case "not-run":
|
||||
return status.trim().toLowerCase() as PackageScanStatus;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageReleaseScanStatus(
|
||||
release: PackageReleaseSecurityLike,
|
||||
): Exclude<PackageScanStatus, undefined> {
|
||||
const staticStatus = normalizePackageScanStatus(release.staticScan?.status);
|
||||
if (staticStatus === "malicious") return "malicious";
|
||||
|
||||
const vtStatus = normalizePackageScanStatus(release.vtAnalysis?.status);
|
||||
if (vtStatus === "malicious") return "malicious";
|
||||
|
||||
const verificationStatus = normalizePackageScanStatus(release.verification?.scanStatus);
|
||||
if (verificationStatus === "malicious") return "malicious";
|
||||
|
||||
if (vtStatus) return vtStatus;
|
||||
if (verificationStatus && verificationStatus !== "not-run") return verificationStatus;
|
||||
if (release.sha256hash) return "pending";
|
||||
|
||||
return verificationStatus ?? "not-run";
|
||||
}
|
||||
|
||||
export function isPackageBlockedFromPublic(scanStatus: PackageScanStatus) {
|
||||
return scanStatus === "malicious";
|
||||
}
|
||||
|
||||
export function getPackageDownloadSecurityBlock(release: PackageReleaseSecurityLike) {
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+48
-11
@@ -43,6 +43,23 @@ function synthesizePersonalPublisher(user: Doc<"users">): Doc<"publishers"> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUserOrFallback(
|
||||
ctx: DbCtx,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
try {
|
||||
const publisher = await getPersonalPublisherForUser(ctx, user._id);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
} catch (error) {
|
||||
if (!isMissingPublisherTableError(error)) throw error;
|
||||
}
|
||||
return synthesizePersonalPublisher(user);
|
||||
}
|
||||
|
||||
export function normalizePublisherHandle(handle: string | undefined | null) {
|
||||
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
@@ -80,6 +97,36 @@ export async function getPublisherByHandle(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalized))
|
||||
.unique();
|
||||
if (user) return user;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalized);
|
||||
if (!publisher || !isPublisherActive(publisher) || publisher.kind !== "user" || !publisher.linkedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ctx.db.get(publisher.linkedUserId);
|
||||
}
|
||||
|
||||
export async function getActiveUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const user = await getUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(
|
||||
ctx: DbCtx,
|
||||
userId: Id<"users">,
|
||||
@@ -290,15 +337,5 @@ export async function getOwnerPublisher(
|
||||
if (!params.ownerUserId) return null;
|
||||
const user = await ctx.db.get(params.ownerUserId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
try {
|
||||
const publisher = await getPersonalPublisherForUser(ctx, params.ownerUserId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
} catch (error) {
|
||||
if (!isMissingPublisherTableError(error)) throw error;
|
||||
}
|
||||
return synthesizePersonalPublisher(user);
|
||||
return await getPersonalPublisherForUserOrFallback(ctx, user);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
backfillPackageReleaseScansInternal,
|
||||
getPackageReleaseScanBackfillBatchInternal,
|
||||
getByName,
|
||||
list,
|
||||
publishPackage,
|
||||
publishPackageForUserInternal,
|
||||
getVersionByName,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
listPublicPage,
|
||||
listPageForViewerInternal,
|
||||
listVersions,
|
||||
updateReleaseStaticScanInternal,
|
||||
softDeletePackageInternal,
|
||||
searchForViewerInternal,
|
||||
searchPublic,
|
||||
@@ -33,6 +37,21 @@ const getByNameHandler = (
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
const listHandler = (
|
||||
list as unknown as WrappedHandler<
|
||||
{
|
||||
ownerUserId?: string;
|
||||
ownerPublisherId?: string;
|
||||
limit?: number;
|
||||
},
|
||||
Array<{
|
||||
name: string;
|
||||
pendingReview?: boolean;
|
||||
scanStatus?: string;
|
||||
latestRelease: { vtStatus: string | null; staticScanStatus: string | null } | null;
|
||||
}>
|
||||
>
|
||||
)._handler;
|
||||
const getVersionByNameHandler = (
|
||||
getVersionByName as unknown as WrappedHandler<
|
||||
{ name: string; version: string },
|
||||
@@ -157,6 +176,59 @@ const publishPackageForUserInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const getPackageReleaseScanBackfillBatchInternalHandler = (
|
||||
getPackageReleaseScanBackfillBatchInternal as unknown as WrappedHandler<
|
||||
{
|
||||
cursor?: number;
|
||||
batchSize?: number;
|
||||
prioritizeRecent?: boolean;
|
||||
},
|
||||
{
|
||||
releases: Array<{
|
||||
releaseId: string;
|
||||
packageId: string;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
const backfillPackageReleaseScansInternalHandler = (
|
||||
backfillPackageReleaseScansInternal as unknown as WrappedHandler<
|
||||
{
|
||||
cursor?: number;
|
||||
batchSize?: number;
|
||||
scheduled?: number;
|
||||
},
|
||||
{ scheduled: number; nextCursor: number; done: boolean }
|
||||
>
|
||||
)._handler;
|
||||
const updateReleaseStaticScanInternalHandler = (
|
||||
updateReleaseStaticScanInternal as unknown as WrappedHandler<
|
||||
{
|
||||
releaseId: string;
|
||||
staticScan: {
|
||||
status: "clean" | "suspicious" | "malicious";
|
||||
reasonCodes: string[];
|
||||
findings: Array<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}>;
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
},
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const softDeletePackageInternalHandler = (
|
||||
softDeletePackageInternal as unknown as WrappedHandler<
|
||||
{ userId: string; name: string },
|
||||
@@ -215,6 +287,8 @@ function makePackageDoc(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
scanStatus: "clean",
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
softDeletedAt: undefined,
|
||||
@@ -240,6 +314,8 @@ function makeDigestCtx(options: {
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
exactPackages?: Array<Record<string, unknown>>;
|
||||
exactDigests?: Array<Record<string, unknown>>;
|
||||
publisherMemberships?: Record<string, "owner" | "admin" | "publisher">;
|
||||
}) {
|
||||
const pageByTable = new Map<
|
||||
@@ -308,6 +384,55 @@ function makeDigestCtx(options: {
|
||||
ctx: {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder?: (q: {
|
||||
eq: (field: string, value: string) => unknown;
|
||||
gte: (field: string, value: string) => unknown;
|
||||
lt: (field: string, value: string) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
let matchedValue = "";
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
eq: (_field: string, value: string) => {
|
||||
matchedValue = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
gte: (_field: string, value: string) => {
|
||||
lowerBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
lt: (_field: string, value: string) => {
|
||||
upperBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
if (indexName !== "by_name" && indexName !== "by_runtime_id") {
|
||||
throw new Error(`Unexpected packages index ${indexName}`);
|
||||
}
|
||||
const matches = (options.exactPackages ?? []).filter((pkg) =>
|
||||
indexName === "by_name"
|
||||
? matchedValue
|
||||
? String(pkg.normalizedName) === matchedValue
|
||||
: String(pkg.normalizedName) >= lowerBound && String(pkg.normalizedName) < upperBound
|
||||
: matchedValue
|
||||
? String(pkg.runtimeId) === matchedValue
|
||||
: String(pkg.runtimeId) >= lowerBound && String(pkg.runtimeId) < upperBound,
|
||||
);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(matches[0] ?? null),
|
||||
take: vi.fn().mockResolvedValue(matches),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
@@ -340,7 +465,64 @@ function makeDigestCtx(options: {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table !== "packageSearchDigest" && table !== "packageCapabilitySearchDigest") {
|
||||
if (table === "packageSearchDigest") {
|
||||
tableNames.push(table);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder?: (q: {
|
||||
eq: (field: string, value: string | undefined) => unknown;
|
||||
gte: (field: string, value: string) => unknown;
|
||||
lt: (field: string, value: string) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
if (indexName === "by_package") {
|
||||
let packageId = "";
|
||||
const queryBuilder = {
|
||||
eq: (field: string, value: string | undefined) => {
|
||||
if (field === "packageId") packageId = value ?? "";
|
||||
return queryBuilder;
|
||||
},
|
||||
gte: () => queryBuilder,
|
||||
lt: () => queryBuilder,
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
const match = (options.exactDigests ?? []).find((digest) => digest.packageId === packageId);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(match ?? null),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_active_normalized_name" || indexName === "by_active_runtime_id") {
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
eq: () => queryBuilder,
|
||||
gte: (_field: string, value: string) => {
|
||||
lowerBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
lt: (_field: string, value: string) => {
|
||||
upperBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
const matches = (options.exactDigests ?? []).filter((digest) =>
|
||||
indexName === "by_active_normalized_name"
|
||||
? String(digest.normalizedName) >= lowerBound &&
|
||||
String(digest.normalizedName) < upperBound
|
||||
: String(digest.runtimeId) >= lowerBound &&
|
||||
String(digest.runtimeId) < upperBound,
|
||||
);
|
||||
return {
|
||||
take: vi.fn().mockResolvedValue(matches),
|
||||
};
|
||||
}
|
||||
return withIndex(table, indexName);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table !== "packageCapabilitySearchDigest") {
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}
|
||||
tableNames.push(table);
|
||||
@@ -998,6 +1180,134 @@ describe("packages public queries", () => {
|
||||
expect(result.map((entry) => entry.package.name)).toContain("demo-plugin");
|
||||
});
|
||||
|
||||
it("includes exact package-name matches before digest scanning", async () => {
|
||||
const exactPkg = makePackageDoc({
|
||||
_id: "packages:exact",
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
});
|
||||
const exactDigest = makeDigest("demo-plugin", {
|
||||
packageId: "packages:exact",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [exactPkg],
|
||||
exactDigests: [exactDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo-plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("includes exact runtime-id matches before digest scanning", async () => {
|
||||
const exactPkg = makePackageDoc({
|
||||
_id: "packages:runtime",
|
||||
name: "runtime-demo",
|
||||
normalizedName: "runtime-demo",
|
||||
runtimeId: "demo.plugin",
|
||||
});
|
||||
const exactDigest = makeDigest("runtime-demo", {
|
||||
packageId: "packages:runtime",
|
||||
runtimeId: "demo.plugin",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [exactPkg],
|
||||
exactDigests: [exactDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo.plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["runtime-demo"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("includes prefix package-name matches before digest scanning", async () => {
|
||||
const prefixPkg = makePackageDoc({
|
||||
_id: "packages:prefix",
|
||||
name: "demo-prefix",
|
||||
normalizedName: "demo-prefix",
|
||||
});
|
||||
const prefixDigest = makeDigest("demo-prefix", {
|
||||
packageId: "packages:prefix",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [prefixPkg],
|
||||
exactDigests: [prefixDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-prefix"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("keeps spaced queries on the scan path without throwing", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("demo-plugin", {
|
||||
displayName: "Demo Plugin",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
});
|
||||
|
||||
it("skips publisher membership lookups for public search rows", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("demo-plugin", {
|
||||
ownerPublisherId: "publishers:org",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
publisherMemberships: {
|
||||
"publishers:org": "publisher",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await searchForViewerInternalHandler(ctx, {
|
||||
query: "demo",
|
||||
limit: 10,
|
||||
viewerUserId: "users:member",
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
expect(ctx.db.query).not.toHaveBeenCalledWith("publisherMembers");
|
||||
});
|
||||
|
||||
it("caps public list scans below the Convex read limit budget", async () => {
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: Array.from({ length: 120 }, (_, index) => ({
|
||||
@@ -1807,9 +2117,15 @@ describe("packages public queries", () => {
|
||||
reasonCodes: expect.arrayContaining(["suspicious.dangerous_exec"]),
|
||||
}),
|
||||
);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
30_000,
|
||||
expect.anything(),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides pending-scan packages from public reads", async () => {
|
||||
it("keeps pending-scan packages visible to public reads", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null);
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -1830,7 +2146,7 @@ describe("packages public queries", () => {
|
||||
};
|
||||
|
||||
const result = await getByNameHandler(ctx as never, { name: "demo-plugin" });
|
||||
expect(result).toBeNull();
|
||||
expect(result?.package?.name).toBe("demo-plugin");
|
||||
});
|
||||
|
||||
it("keeps pending-scan packages visible to the owner", async () => {
|
||||
@@ -1861,6 +2177,108 @@ describe("packages public queries", () => {
|
||||
expect(result?.package?.name).toBe("demo-plugin");
|
||||
});
|
||||
|
||||
it("lists owner packages with pending review and latest release scan state", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const result = await listHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-1") {
|
||||
return makeReleaseDoc({
|
||||
version: "1.0.0",
|
||||
vtAnalysis: { status: "pending" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
});
|
||||
}
|
||||
if (id === "publishers:owner") {
|
||||
return { _id: "publishers:owner", kind: "user", linkedUserId: "users:owner" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName === "by_owner_publisher") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
makePackageDoc({
|
||||
ownerPublisherId: "publishers:owner",
|
||||
scanStatus: "pending",
|
||||
}),
|
||||
]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_owner") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected index ${indexName}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ ownerPublisherId: "publishers:owner", limit: 20 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "demo-plugin",
|
||||
pendingReview: true,
|
||||
scanStatus: "pending",
|
||||
latestRelease: expect.objectContaining({
|
||||
vtStatus: "pending",
|
||||
staticScanStatus: "clean",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no owner packages when the viewer lacks access", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:stranger" as never);
|
||||
const result = await listHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "publishers:owner") {
|
||||
return { _id: "publishers:owner", kind: "user", linkedUserId: "users:owner" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ ownerPublisherId: "publishers:owner", limit: 20 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires auth inside the public publish action", async () => {
|
||||
await expect(
|
||||
publishPackageHandler({ runQuery: vi.fn(), runMutation: vi.fn() } as never, {
|
||||
@@ -1875,3 +2293,247 @@ describe("packages public queries", () => {
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("package scan backfill", () => {
|
||||
it("includes releases missing static scan in the backfill batch", async () => {
|
||||
const result = await getPackageReleaseScanBackfillBatchInternalHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "packageReleases") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:missing-static",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: undefined,
|
||||
},
|
||||
{
|
||||
_id: "packageReleases:fully-scanned",
|
||||
_creationTime: 11,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return makePackageDoc();
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
|
||||
expect(result.releases).toEqual([
|
||||
{
|
||||
releaseId: "packageReleases:missing-static",
|
||||
packageId: "packages:demo",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes recent releases before draining older backlog", async () => {
|
||||
const result = await getPackageReleaseScanBackfillBatchInternalHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "packageReleases") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:recent-vt",
|
||||
_creationTime: 200,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: undefined,
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
},
|
||||
]),
|
||||
})),
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:old-static",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: undefined,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return makePackageDoc();
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ batchSize: 2, prioritizeRecent: true },
|
||||
);
|
||||
|
||||
expect(result.releases).toEqual([
|
||||
{
|
||||
releaseId: "packageReleases:recent-vt",
|
||||
packageId: "packages:demo",
|
||||
needsVt: true,
|
||||
needsLlm: false,
|
||||
needsStatic: false,
|
||||
},
|
||||
{
|
||||
releaseId: "packageReleases:old-static",
|
||||
packageId: "packages:demo",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("schedules static rescans for releases missing only static scan data", async () => {
|
||||
const originalVtApiKey = process.env.VT_API_KEY;
|
||||
process.env.VT_API_KEY = "vt-test-key";
|
||||
|
||||
try {
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await backfillPackageReleaseScansInternalHandler(
|
||||
{
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
releases: [
|
||||
{
|
||||
releaseId: "packageReleases:static-only",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
],
|
||||
nextCursor: 123,
|
||||
done: true,
|
||||
}),
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ scheduled: 1, nextCursor: 123, done: true });
|
||||
expect(runAfter).toHaveBeenCalledTimes(1);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ releaseId: "packageReleases:static-only" }),
|
||||
);
|
||||
} finally {
|
||||
if (originalVtApiKey === undefined) {
|
||||
delete process.env.VT_API_KEY;
|
||||
} else {
|
||||
process.env.VT_API_KEY = originalVtApiKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("promotes latest package scan status when a static rescan finds malware", async () => {
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const release = {
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const pkg = {
|
||||
...makePackageDoc(),
|
||||
_id: "packages:demo",
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await updateReleaseStaticScanInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-1") return release;
|
||||
if (id === "packages:demo") return pkg;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-1",
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["malware.test"],
|
||||
findings: [],
|
||||
summary: "Malware detected",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"packageReleases:demo-1",
|
||||
expect.objectContaining({
|
||||
staticScan: expect.objectContaining({ status: "malicious" }),
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
scanStatus: "malicious",
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
latestVersionSummary: expect.objectContaining({
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+465
-97
@@ -34,25 +34,35 @@ import {
|
||||
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import {
|
||||
isPackageBlockedFromPublic,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./lib/packageSecurity";
|
||||
import { tokenize } from "./lib/searchText";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
|
||||
const MAX_PACKAGE_SCAN_DOCUMENTS = 30_000;
|
||||
const MAX_PUBLIC_LIST_SCAN_PAGES = 200;
|
||||
const MAX_SEARCH_PAGE_SIZE = 200;
|
||||
const MAX_SEARCH_SCAN_PAGES = 200;
|
||||
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
|
||||
const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
|
||||
const internalRefs = internal as unknown as {
|
||||
llmEval: {
|
||||
evaluatePackageReleaseWithLlm: unknown;
|
||||
};
|
||||
packages: {
|
||||
backfillPackageReleaseScansInternal: unknown;
|
||||
scanPackageReleaseStaticallyInternal: unknown;
|
||||
insertReleaseInternal: unknown;
|
||||
getByNameForViewerInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
getPackageReleaseScanBackfillBatchInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
getVersionByNameForViewerInternal: unknown;
|
||||
publishPackageForUserInternal: unknown;
|
||||
updateReleaseStaticScanInternal: unknown;
|
||||
};
|
||||
skills: {
|
||||
getSkillBySlugInternal: unknown;
|
||||
@@ -152,19 +162,6 @@ async function runAfterRef(
|
||||
return await ctx.scheduler.runAfter(delayMs, ref as never, args as never);
|
||||
}
|
||||
|
||||
function toPackageScanStatus(status: string | undefined): Doc<"packages">["scanStatus"] {
|
||||
switch (status) {
|
||||
case "clean":
|
||||
case "suspicious":
|
||||
case "malicious":
|
||||
case "pending":
|
||||
case "not-run":
|
||||
return status;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type PublicPackageDoc = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
@@ -185,8 +182,38 @@ type PublicPackageDoc = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function isPackageBlockedFromPublic(scanStatus: Doc<"packages">["scanStatus"]) {
|
||||
return scanStatus === "pending" || scanStatus === "malicious";
|
||||
type DashboardPackageListItem = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: PackageFamily;
|
||||
channel: PackageChannel;
|
||||
isOfficial: boolean;
|
||||
runtimeId: string | null;
|
||||
sourceRepo: string | null;
|
||||
summary: string | null;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
latestVersion: string | null;
|
||||
stats: Doc<"packages">["stats"];
|
||||
verification: Doc<"packages">["verification"];
|
||||
scanStatus: Doc<"packages">["scanStatus"];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pendingReview?: true;
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function requiresPrivilegedPackageAccess(
|
||||
digest: Pick<PackageDigestLike, "channel" | "scanStatus">,
|
||||
) {
|
||||
return digest.channel === "private" || isPackageBlockedFromPublic(digest.scanStatus);
|
||||
}
|
||||
|
||||
async function viewerCanAccessPackageOwner(
|
||||
@@ -212,6 +239,28 @@ async function viewerCanAccessPackageOwner(
|
||||
return await membershipPromise;
|
||||
}
|
||||
|
||||
async function canViewerReadPackage(
|
||||
ctx: DbReaderCtx,
|
||||
digest: Pick<
|
||||
PackageDigestLike,
|
||||
"channel" | "scanStatus" | "ownerUserId" | "ownerPublisherId"
|
||||
>,
|
||||
viewerUserId: Id<"users"> | undefined,
|
||||
membershipCache?: Map<string, Promise<boolean>>,
|
||||
) {
|
||||
if (!requiresPrivilegedPackageAccess(digest)) return true;
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
}
|
||||
|
||||
function toPublicPackage(
|
||||
pkg: Doc<"packages"> | null | undefined,
|
||||
latestRelease?: Pick<Doc<"packageReleases">, "version" | "softDeletedAt"> | null,
|
||||
@@ -283,6 +332,110 @@ function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListIt
|
||||
};
|
||||
}
|
||||
|
||||
async function toDashboardPackageListItem(
|
||||
ctx: DbReaderCtx,
|
||||
pkg: Doc<"packages">,
|
||||
): Promise<DashboardPackageListItem | null> {
|
||||
if (pkg.softDeletedAt) return null;
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
return {
|
||||
_id: pkg._id,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family,
|
||||
channel: pkg.channel,
|
||||
isOfficial: pkg.isOfficial,
|
||||
runtimeId: pkg.runtimeId ?? null,
|
||||
sourceRepo: pkg.sourceRepo ?? null,
|
||||
summary: pkg.summary ?? null,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
latestVersion: pkg.latestVersionSummary?.version ?? null,
|
||||
stats: pkg.stats,
|
||||
verification: pkg.verification,
|
||||
scanStatus: pkg.scanStatus,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
|
||||
latestRelease:
|
||||
latestRelease && !latestRelease.softDeletedAt
|
||||
? {
|
||||
version: latestRelease.version,
|
||||
createdAt: latestRelease.createdAt,
|
||||
vtStatus: latestRelease.vtAnalysis?.status ?? null,
|
||||
llmStatus: latestRelease.llmAnalysis?.status ?? null,
|
||||
staticScanStatus: latestRelease.staticScan?.status ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function listDashboardPackagesForOwnerPublisher(
|
||||
ctx: QueryCtx,
|
||||
ownerPublisherId: Id<"publishers">,
|
||||
viewerUserId: Id<"users">,
|
||||
limit: number,
|
||||
) {
|
||||
const takeLimit = Math.min(limit * 5, 500);
|
||||
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
||||
const membership =
|
||||
(await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", ownerPublisherId).eq("userId", viewerUserId),
|
||||
)
|
||||
.unique()) ?? null;
|
||||
const isOwnDashboard = Boolean(
|
||||
membership ||
|
||||
(ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === viewerUserId),
|
||||
);
|
||||
if (!isOwnDashboard) return [];
|
||||
|
||||
const scopedEntries = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.order("desc")
|
||||
.take(takeLimit);
|
||||
const legacyEntries =
|
||||
ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId
|
||||
? await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerPublisher.linkedUserId!))
|
||||
.order("desc")
|
||||
.take(takeLimit)
|
||||
: [];
|
||||
|
||||
const combined = [...scopedEntries, ...legacyEntries].filter(
|
||||
(pkg, index, all) =>
|
||||
!pkg.softDeletedAt &&
|
||||
(!pkg.ownerPublisherId || pkg.ownerPublisherId === ownerPublisherId) &&
|
||||
all.findIndex((candidate) => candidate._id === pkg._id) === index,
|
||||
);
|
||||
const limited = combined.slice(0, limit);
|
||||
return (
|
||||
await Promise.all(limited.map(async (pkg) => await toDashboardPackageListItem(ctx, pkg)))
|
||||
).filter((pkg): pkg is DashboardPackageListItem => Boolean(pkg));
|
||||
}
|
||||
|
||||
async function listDashboardPackagesForOwnerUser(
|
||||
ctx: QueryCtx,
|
||||
ownerUserId: Id<"users">,
|
||||
viewerUserId: Id<"users">,
|
||||
limit: number,
|
||||
) {
|
||||
if (ownerUserId !== viewerUserId) return [];
|
||||
const takeLimit = Math.min(limit * 5, 500);
|
||||
const entries = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
||||
.order("desc")
|
||||
.take(takeLimit);
|
||||
const filtered = entries.filter((pkg) => !pkg.softDeletedAt).slice(0, limit);
|
||||
return (
|
||||
await Promise.all(filtered.map(async (pkg) => await toDashboardPackageListItem(ctx, pkg)))
|
||||
).filter((pkg): pkg is DashboardPackageListItem => Boolean(pkg));
|
||||
}
|
||||
|
||||
function encodePublicPageCursor(state: PublicPageCursorState) {
|
||||
if (state.done && state.offset === 0) return "";
|
||||
return `${PUBLIC_PAGE_CURSOR_PREFIX}${JSON.stringify(state)}`;
|
||||
@@ -320,6 +473,7 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
const needle = queryText.toLowerCase();
|
||||
const normalized = digest.normalizedName.toLowerCase();
|
||||
const display = digest.displayName.toLowerCase();
|
||||
const runtimeId = digest.runtimeId?.toLowerCase() ?? "";
|
||||
const summary = (digest.summary ?? "").toLowerCase();
|
||||
let score = 0;
|
||||
if (normalized === needle) score += 200;
|
||||
@@ -330,6 +484,10 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
else if (display.startsWith(needle)) score += 70;
|
||||
else if (display.includes(needle)) score += 40;
|
||||
|
||||
if (runtimeId === needle) score += 180;
|
||||
else if (runtimeId.startsWith(needle)) score += 90;
|
||||
else if (runtimeId.includes(needle)) score += 45;
|
||||
|
||||
if (summary.includes(needle)) score += 20;
|
||||
if ((digest.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
|
||||
score += 12;
|
||||
@@ -338,6 +496,55 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
return score;
|
||||
}
|
||||
|
||||
function prefixUpperBound(value: string) {
|
||||
return `${value}\uffff`;
|
||||
}
|
||||
|
||||
function maybeNormalizePackageQuery(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return normalizePackageName(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectPackageSearchDigests(
|
||||
ctx: DbReaderCtx,
|
||||
queryText: string,
|
||||
): Promise<PackageDigestLike[]> {
|
||||
const normalizedQuery = maybeNormalizePackageQuery(queryText);
|
||||
const queryTokens = tokenize(queryText).filter((token) => token.length > 1);
|
||||
const runtimePrefix = queryTokens.length === 1 ? queryTokens[0] : queryText;
|
||||
const [nameDigests, runtimeDigests] = await Promise.all([
|
||||
normalizedQuery
|
||||
? ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_active_normalized_name", (q) =>
|
||||
q.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedName", normalizedQuery)
|
||||
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
|
||||
)
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
runtimePrefix
|
||||
? ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_active_runtime_id", (q) =>
|
||||
q.eq("softDeletedAt", undefined)
|
||||
.gte("runtimeId", runtimePrefix)
|
||||
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
|
||||
)
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return [...nameDigests, ...runtimeDigests].filter(
|
||||
(digest, index, all) =>
|
||||
all.findIndex((candidate) => candidate?.packageId === digest?.packageId) === index,
|
||||
) as PackageDigestLike[];
|
||||
}
|
||||
|
||||
function buildPackageDigestQuery(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
@@ -572,9 +779,7 @@ async function getReadablePackageByName(
|
||||
const normalizedName = normalizePackageName(name);
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
||||
if (!pkg || pkg.softDeletedAt) return null;
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
|
||||
if (pkg.channel === "private" && !isPrivilegedViewer) return null;
|
||||
if (isPackageBlockedFromPublic(pkg.scanStatus) && !isPrivilegedViewer) return null;
|
||||
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId))) return null;
|
||||
return pkg;
|
||||
}
|
||||
|
||||
@@ -710,6 +915,31 @@ export const getVersionByNameForViewerInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
ownerUserId: v.optional(v.id("users")),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const viewerUserId = await getAuthUserId(ctx);
|
||||
if (!viewerUserId) return [];
|
||||
const limit = Math.max(1, Math.min(args.limit ?? 50, 100));
|
||||
if (args.ownerPublisherId) {
|
||||
return await listDashboardPackagesForOwnerPublisher(
|
||||
ctx,
|
||||
args.ownerPublisherId,
|
||||
viewerUserId,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
if (args.ownerUserId) {
|
||||
return await listDashboardPackagesForOwnerUser(ctx, args.ownerUserId, viewerUserId, limit);
|
||||
}
|
||||
return await listDashboardPackagesForOwnerUser(ctx, viewerUserId, viewerUserId, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublicPage = query({
|
||||
args: {
|
||||
family: v.optional(
|
||||
@@ -760,18 +990,8 @@ async function listPackagePageImpl(
|
||||
}
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) => {
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
};
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const targetCount = args.paginationOpts.numItems;
|
||||
const collected: PublicPackageListItem[] = [];
|
||||
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
@@ -909,18 +1129,8 @@ async function searchPackagesImpl(
|
||||
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) => {
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
};
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
@@ -937,39 +1147,60 @@ async function searchPackagesImpl(
|
||||
});
|
||||
const matches: Array<{ score: number; package: PublicPackageListItem }> = [];
|
||||
const seen = new Set<string>();
|
||||
const pageSize = Math.min(MAX_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
let cursor: string | null = null;
|
||||
let done = false;
|
||||
let loops = 0;
|
||||
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
|
||||
const directDigests = args.capabilityTag
|
||||
? []
|
||||
: await resolveDirectPackageSearchDigests(ctx, queryText);
|
||||
for (const digest of directDigests) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
}
|
||||
|
||||
while (!done && loops < MAX_SEARCH_SCAN_PAGES && remainingScanBudget > 0) {
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(pageSize, remainingScanBudget);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor, numItems: effectivePageSize });
|
||||
for (const digest of page.page) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
if (matches.length < targetCount) {
|
||||
const pageSize = Math.min(MAX_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
let cursor: string | null = null;
|
||||
let done = false;
|
||||
let loops = 0;
|
||||
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
|
||||
|
||||
while (!done && loops < MAX_SEARCH_SCAN_PAGES && remainingScanBudget > 0) {
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(pageSize, remainingScanBudget);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor, numItems: effectivePageSize });
|
||||
for (const digest of page.page) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
|
||||
return matches
|
||||
@@ -1088,18 +1319,40 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
prioritizeRecent: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 50, 200));
|
||||
const cursor = args.cursor ?? 0;
|
||||
const prioritizeRecent = args.prioritizeRecent ?? true;
|
||||
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 3);
|
||||
const [recentReleases, backlogReleases] = await Promise.all([
|
||||
prioritizeRecent
|
||||
? ctx.db.query("packageReleases").order("desc").take(batchSize * 2)
|
||||
: Promise.resolve([]),
|
||||
ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 3),
|
||||
]);
|
||||
|
||||
const results: Array<{ releaseId: Id<"packageReleases">; packageId: Id<"packages"> }> = [];
|
||||
const releases = [
|
||||
...recentReleases,
|
||||
...backlogReleases.filter(
|
||||
(release, index, all) =>
|
||||
recentReleases.findIndex((candidate) => candidate._id === release._id) === -1 &&
|
||||
all.findIndex((candidate) => candidate._id === release._id) === index,
|
||||
),
|
||||
];
|
||||
|
||||
const results: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
packageId: Id<"packages">;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const release of releases) {
|
||||
@@ -1112,18 +1365,22 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
|
||||
const needsVt = !release.sha256hash || !release.vtAnalysis;
|
||||
const needsLlm = !release.llmAnalysis || release.llmAnalysis.status === "error";
|
||||
if (!needsVt && !needsLlm) continue;
|
||||
const needsStatic = !release.staticScan;
|
||||
if (!needsVt && !needsLlm && !needsStatic) continue;
|
||||
|
||||
results.push({
|
||||
releaseId: release._id,
|
||||
packageId: release.packageId,
|
||||
needsVt,
|
||||
needsLlm,
|
||||
needsStatic,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
releases: results,
|
||||
nextCursor,
|
||||
done: releases.length < batchSize * 3,
|
||||
done: backlogReleases.length < batchSize * 3,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1142,7 +1399,7 @@ async function publishPackageImpl(
|
||||
throw new ConvexError("Skill packages must use the skills publish flow");
|
||||
}
|
||||
await requireGitHubAccountAge(ctx, actorUserId);
|
||||
const ownerTarget = await runQueryRef<{
|
||||
const ownerTarget = await runMutationRef<{
|
||||
publisherId: Id<"publishers">;
|
||||
linkedUserId?: Id<"users">;
|
||||
} | null>(ctx, internalRefs.publishers.resolvePublishTargetForUserInternal, {
|
||||
@@ -1275,7 +1532,7 @@ async function publishPackageImpl(
|
||||
},
|
||||
);
|
||||
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
await runAfterRef(ctx, INITIAL_PACKAGE_VT_SCAN_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: publishResult.releaseId,
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
@@ -1544,10 +1801,10 @@ function isReleaseActive(release: Doc<"packageReleases"> | null | undefined) {
|
||||
async function syncLatestPackageVerification(
|
||||
ctx: MutationCtx,
|
||||
release: Doc<"packageReleases">,
|
||||
scanStatus: Doc<"packages">["scanStatus"],
|
||||
) {
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
if (!pkg || pkg.latestReleaseId !== release._id) return;
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
|
||||
const nextVerification = pkg.verification
|
||||
? {
|
||||
@@ -1595,7 +1852,10 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
const patch: Partial<Doc<"packageReleases">> = {};
|
||||
if (args.sha256hash !== undefined) patch.sha256hash = args.sha256hash;
|
||||
if (args.vtAnalysis !== undefined) {
|
||||
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
|
||||
const nextScanStatus = resolvePackageReleaseScanStatus({
|
||||
...activeRelease,
|
||||
vtAnalysis: args.vtAnalysis,
|
||||
});
|
||||
patch.vtAnalysis = args.vtAnalysis;
|
||||
patch.verification = activeRelease.verification
|
||||
? {
|
||||
@@ -1608,12 +1868,7 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
}
|
||||
if (args.vtAnalysis !== undefined) {
|
||||
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
|
||||
await syncLatestPackageVerification(
|
||||
ctx,
|
||||
{ ...activeRelease, ...patch } as Doc<"packageReleases">,
|
||||
nextScanStatus,
|
||||
);
|
||||
await syncLatestPackageVerification(ctx, { ...activeRelease, ...patch } as Doc<"packageReleases">);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1649,6 +1904,103 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateReleaseStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await ctx.db.get(args.releaseId);
|
||||
if (!release || release.softDeletedAt) return;
|
||||
const activeRelease = release;
|
||||
|
||||
const patch: Partial<Doc<"packageReleases">> = {
|
||||
staticScan: args.staticScan,
|
||||
};
|
||||
if (activeRelease.verification) {
|
||||
const nextScanStatus = resolvePackageReleaseScanStatus({
|
||||
...activeRelease,
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
patch.verification = activeRelease.verification
|
||||
? {
|
||||
...activeRelease.verification,
|
||||
scanStatus: nextScanStatus,
|
||||
}
|
||||
: activeRelease.verification;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
|
||||
await syncLatestPackageVerification(ctx, { ...activeRelease, ...patch } as Doc<"packageReleases">);
|
||||
},
|
||||
});
|
||||
|
||||
export const scanPackageReleaseStaticallyInternal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await runQueryRef<Doc<"packageReleases"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getReleaseByIdInternal,
|
||||
{ releaseId: args.releaseId },
|
||||
);
|
||||
if (!release || release.softDeletedAt) {
|
||||
return { ok: true as const, skipped: "missing_release" as const };
|
||||
}
|
||||
const activeRelease = release;
|
||||
|
||||
const pkg = await runQueryRef<Doc<"packages"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getPackageByIdInternal,
|
||||
{ packageId: activeRelease.packageId },
|
||||
);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
return { ok: true as const, skipped: "missing_package" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
summary: pkg.summary,
|
||||
metadata: {
|
||||
packageJson: activeRelease.extractedPackageJson,
|
||||
pluginManifest: activeRelease.extractedPluginManifest,
|
||||
bundleManifest: activeRelease.normalizedBundleManifest,
|
||||
source: activeRelease.source,
|
||||
},
|
||||
files: activeRelease.files,
|
||||
});
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseStaticScanInternal, {
|
||||
releaseId: args.releaseId,
|
||||
staticScan,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
status: staticScan.status,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillPackageReleaseScansInternal = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
@@ -1660,20 +2012,36 @@ export const backfillPackageReleaseScansInternal = internalAction({
|
||||
const batch = (await runQueryRef(ctx, internalRefs.packages.getPackageReleaseScanBackfillBatchInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
prioritizeRecent: args.cursor === undefined,
|
||||
})) as {
|
||||
releases: Array<{ releaseId: Id<"packageReleases"> }>;
|
||||
releases: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
let scheduled = args.scheduled ?? 0;
|
||||
const vtEnabled = Boolean(process.env.VT_API_KEY);
|
||||
for (const release of batch.releases) {
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
if (release.needsVt && vtEnabled) {
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
if (release.needsLlm) {
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
if (release.needsStatic) {
|
||||
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addMember,
|
||||
listMine,
|
||||
migrateLegacyPublisherHandleToOrgInternal,
|
||||
removeMember,
|
||||
} from "./publishers";
|
||||
@@ -45,6 +46,10 @@ const migrateLegacyPublisherHandleToOrgInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const listMineHandler = (
|
||||
listMine as unknown as WrappedHandler<Record<string, never>, Array<unknown>>
|
||||
)._handler;
|
||||
|
||||
describe("publishers membership controls", () => {
|
||||
it("prevents admins from promoting members to owner", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
@@ -164,6 +169,245 @@ describe("publishers membership controls", () => {
|
||||
),
|
||||
).rejects.toThrow("Publisher must have at least one owner");
|
||||
});
|
||||
|
||||
it("adds a member when the requested handle resolves via a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const publisherMembers: Array<Record<string, unknown>> = [
|
||||
{
|
||||
_id: "publisherMembers:owner",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
},
|
||||
];
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publisherMembers") {
|
||||
const row = { _id: "publisherMembers:new", ...value };
|
||||
publisherMembers.push(row);
|
||||
return row._id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
if (table === "publishers") return "publishers:jaredforreal";
|
||||
throw new Error(`unexpected insert ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return { _id: id };
|
||||
if (id === "users:jared") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
name: "JaredForReal",
|
||||
displayName: "Jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "zai-org",
|
||||
displayName: "ZAI Org",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:jaredforreal") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
if (indexName !== "by_publisher_user") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
publisherMembers.find(
|
||||
(member) => member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
if (indexName !== "handle") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (indexName === "by_handle" && handle === "jaredforreal") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
addMemberHandler(
|
||||
ctx as never,
|
||||
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherMembers",
|
||||
expect.objectContaining({
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:jared",
|
||||
role: "admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher bootstrap", () => {
|
||||
it("lists a synthesized personal publisher when membership rows are missing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:alice") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
||||
return { collect: vi.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName !== "by_linked_user") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return { unique: vi.fn().mockResolvedValue(null) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listMineHandler(ctx as never, {} as never)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "owner",
|
||||
publisher: expect.objectContaining({
|
||||
handle: "alice",
|
||||
kind: "user",
|
||||
linkedUserId: "users:alice",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy publisher migration", () => {
|
||||
|
||||
+26
-17
@@ -6,8 +6,10 @@ import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, requireUser } from "./lib/access";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getPersonalPublisherForUserOrFallback,
|
||||
getPersonalPublisherForUser,
|
||||
isPublisherRoleAllowed,
|
||||
normalizePublisherHandle,
|
||||
@@ -350,7 +352,7 @@ export const ensurePersonalPublisherInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const resolvePublishTargetForUserInternal = internalQuery({
|
||||
export const resolvePublishTargetForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
@@ -361,14 +363,9 @@ export const resolvePublishTargetForUserInternal = internalQuery({
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
const minimumRole = args.minimumRole ?? "publisher";
|
||||
const requestedHandle = normalizePublisherHandle(args.ownerHandle);
|
||||
const personal =
|
||||
actor.personalPublisherId
|
||||
? await ctx.db.get(actor.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, actor._id);
|
||||
const personal = await ensurePersonalPublisherForUser(ctx, actor);
|
||||
if (!personal) throw new ConvexError("Personal publisher not found");
|
||||
if (!requestedHandle) {
|
||||
if (!personal || personal.deletedAt || personal.deactivatedAt) {
|
||||
throw new ConvexError("Personal publisher not found");
|
||||
}
|
||||
return {
|
||||
publisherId: personal._id,
|
||||
handle: personal.handle,
|
||||
@@ -408,6 +405,8 @@ export const listMine = query({
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return [];
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
@@ -419,11 +418,11 @@ export const listMine = query({
|
||||
if (!publicPublisher) return null;
|
||||
return {
|
||||
publisher: publicPublisher,
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return publishers.filter(
|
||||
const visiblePublishers = publishers.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
@@ -431,6 +430,19 @@ export const listMine = query({
|
||||
role: Doc<"publisherMembers">["role"];
|
||||
} => Boolean(item),
|
||||
);
|
||||
const personalPublisher = toPublicPublisher(
|
||||
await getPersonalPublisherForUserOrFallback(ctx, user),
|
||||
);
|
||||
if (
|
||||
personalPublisher &&
|
||||
!visiblePublishers.some((entry) => entry.publisher._id === personalPublisher._id)
|
||||
) {
|
||||
visiblePublishers.unshift({
|
||||
publisher: personalPublisher,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
return visiblePublishers;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -573,11 +585,8 @@ export const addMember = mutation({
|
||||
}
|
||||
const handle = normalizePublisherHandle(args.userHandle);
|
||||
if (!handle) throw new ConvexError("User handle is required");
|
||||
const targetUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
|
||||
const targetUser = await getActiveUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!targetUser) {
|
||||
throw new ConvexError(`User "@${handle}" not found`);
|
||||
}
|
||||
await ensurePersonalPublisherForUser(ctx, targetUser);
|
||||
|
||||
@@ -808,6 +808,8 @@ const packageSearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_normalized_name", ["softDeletedAt", "normalizedName", "updatedAt"])
|
||||
.index("by_active_runtime_id", ["softDeletedAt", "runtimeId", "updatedAt"])
|
||||
.index("by_active_name", ["softDeletedAt", "displayName"]);
|
||||
|
||||
const packageCapabilitySearchDigest = defineTable({
|
||||
|
||||
+236
-3
@@ -46,8 +46,8 @@ describe("search helpers", () => {
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
// With incremental hydration, empty vector results skip the hydrate call entirely.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(fallback); // lexicalFallbackSkills (only call)
|
||||
// Slug-like queries now do an indexed exact-slug lookup before lexical fallback.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(fallback);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -183,6 +183,7 @@ describe("search helpers", () => {
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce(vectorEntries) // hydrateResults
|
||||
.mockResolvedValueOnce(fallbackEntries); // lexicalFallbackSkills
|
||||
|
||||
@@ -204,6 +205,235 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = Array.from({ length: 10 }, (_, index) => ({
|
||||
embeddingId: `skillEmbeddings:${index}`,
|
||||
skill: makePublicSkill({
|
||||
id: `skills:${index}`,
|
||||
slug: `downloader-${index}`,
|
||||
displayName: `Downloader ${index}`,
|
||||
downloads: 100 - index,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
}));
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue(
|
||||
vectorEntries.map((entry, index) => ({ _id: entry.embeddingId, _score: 0.9 - index * 0.01 })),
|
||||
),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result[0].skill.slug).toBe("skill-downloader");
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("omits exact slug injection when nonSuspiciousOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, nonSuspiciousOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("omits exact slug injection when highlightedOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: {
|
||||
...makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
||||
},
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, highlightedOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("deduplicates exact slug injection against vector exact matches", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const sharedSkill = makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 100,
|
||||
});
|
||||
const exactSlugEntry = {
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:exact",
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
embeddingId: "skillEmbeddings:other",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:other",
|
||||
slug: "downloader-2",
|
||||
displayName: "Downloader 2",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: "skillEmbeddings:exact", _score: 0.95 },
|
||||
{ _id: "skillEmbeddings:other", _score: 0.8 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.filter((entry) => entry.skill._id === "skills:exact")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips duplicate slug lookup inside lexical fallback when search action already did it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const fallbackEntries = [
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: "skills:orf",
|
||||
slug: "orf",
|
||||
displayName: "ORF",
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "steipete",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementationOnce(async (_ref: unknown, args: { skipExactSlugLookup?: boolean }) => {
|
||||
expect(args.skipExactSlugLookup).toBe(true);
|
||||
return fallbackEntries;
|
||||
});
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "orf", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("orf");
|
||||
});
|
||||
|
||||
it("filters suspicious vector results in hydrateResults when requested", async () => {
|
||||
const result = await hydrateResultsHandler(
|
||||
{
|
||||
@@ -525,7 +755,10 @@ describe("search helpers", () => {
|
||||
|
||||
const hydrateCalls: string[][] = [];
|
||||
const runQuery = vi.fn(
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string }) => {
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string; slug?: string }) => {
|
||||
if (args.slug) {
|
||||
return null; // getExactSkillSlugMatch
|
||||
}
|
||||
if (args.embeddingIds) {
|
||||
hydrateCalls.push(args.embeddingIds);
|
||||
return args.embeddingIds.map((embeddingId: string) => ({
|
||||
|
||||
+51
-4
@@ -116,6 +116,10 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export const searchSkills: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
@@ -128,6 +132,17 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
if (!query) return [];
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
const rawExactSlugMatch =
|
||||
isSlugLikeQuery(query)
|
||||
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
||||
slug: query.toLowerCase(),
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
})) as SkillSearchEntry | null)
|
||||
: null;
|
||||
const exactSlugMatch =
|
||||
rawExactSlugMatch && (!args.highlightedOnly || isSkillHighlighted(rawExactSlugMatch.skill))
|
||||
? rawExactSlugMatch
|
||||
: null;
|
||||
let vector: number[];
|
||||
try {
|
||||
vector = await generateEmbedding(query);
|
||||
@@ -192,8 +207,12 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
candidateLimit = nextLimit;
|
||||
}
|
||||
|
||||
const primaryMatches = exactSlugMatch
|
||||
? mergeUniqueBySkillId([exactSlugMatch], exactMatches)
|
||||
: exactMatches;
|
||||
|
||||
const fallbackMatches =
|
||||
exactMatches.length >= limit
|
||||
primaryMatches.length >= limit
|
||||
? []
|
||||
: ((await ctx.runQuery(internal.search.lexicalFallbackSkills, {
|
||||
query,
|
||||
@@ -201,9 +220,9 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
limit: Math.min(Math.max(limit * 4, 200), FALLBACK_SCAN_LIMIT),
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
skipExactSlugLookup: true,
|
||||
})) as SkillSearchEntry[]);
|
||||
|
||||
const mergedMatches = mergeUniqueBySkillId(exactMatches, fallbackMatches);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
|
||||
|
||||
return mergedMatches
|
||||
.map((entry) => {
|
||||
@@ -225,6 +244,33 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
export const getExactSkillSlugMatch = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry | null> => {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
if (!skill || skill.softDeletedAt) return null;
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
|
||||
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
||||
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
|
||||
return {
|
||||
skill: publicSkill,
|
||||
version: null,
|
||||
ownerHandle: resolved.ownerHandle,
|
||||
owner: resolved.owner,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const hydrateResults = internalQuery({
|
||||
args: {
|
||||
embeddingIds: v.array(v.id("skillEmbeddings")),
|
||||
@@ -285,6 +331,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
skipExactSlugLookup: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
||||
@@ -298,7 +345,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
|
||||
// Exact slug match via the skills table (only one row, cheap).
|
||||
const slugQuery = args.query.trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,242 @@ describe("skillTransfers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:1") return { _id: "users:1", handle: "owner" };
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: undefined,
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return {
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
collect: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:1",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@alice",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:new",
|
||||
toUserHandle: "alice",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers",
|
||||
expect.objectContaining({
|
||||
toUserId: "users:2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const newPublisher = {
|
||||
_id: "publishers:alice",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
const existingMember = {
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:alice",
|
||||
userId: "users:2",
|
||||
role: "owner",
|
||||
};
|
||||
const aliases = [
|
||||
{
|
||||
_id: "skillSlugAliases:1",
|
||||
slug: "demo-old",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
{
|
||||
_id: "skillSlugAliases:2",
|
||||
slug: "demo-legacy",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
];
|
||||
|
||||
const result = (await acceptTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: "alice",
|
||||
personalPublisherId: "publishers:alice",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
}
|
||||
if (id === "skillOwnershipTransfers:1") {
|
||||
return {
|
||||
_id: "skillOwnershipTransfers:1",
|
||||
skillId: "skills:1",
|
||||
fromUserId: "users:1",
|
||||
toUserId: "users:2",
|
||||
status: "pending",
|
||||
requestedAt: Date.now() - 1_000,
|
||||
expiresAt: Date.now() + 10_000,
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return newPublisher;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skillSlugAliases") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_skill");
|
||||
return {
|
||||
collect: async () => aliases,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_handle");
|
||||
return {
|
||||
unique: async () => newPublisher,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_publisher_user");
|
||||
return {
|
||||
unique: async () => existingMember,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
transferId: "skillOwnershipTransfers:1",
|
||||
} as never,
|
||||
)) as { ok: boolean; skillSlug: string };
|
||||
|
||||
expect(result).toEqual({ ok: true, skillSlug: "demo" });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:2",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers:1",
|
||||
expect.objectContaining({ status: "accepted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal cancels stale transfer when ownership changed", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery } from "./functions";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
@@ -111,11 +115,8 @@ export const requestTransferInternal = internalMutation({
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", toHandle))
|
||||
.first();
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
@@ -157,7 +158,7 @@ export const acceptTransferInternal = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.actorUserId);
|
||||
const newOwner = await requireActiveUserById(ctx, args.actorUserId);
|
||||
|
||||
const transfer = await validatePendingTransferForActor(ctx, {
|
||||
transferId: args.transferId,
|
||||
@@ -173,10 +174,27 @@ export const acceptTransferInternal = internalMutation({
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeCtx() {
|
||||
slug: "padel",
|
||||
displayName: "Padel",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:local",
|
||||
latestVersionId: "skillVersions:1",
|
||||
manualOverride: {
|
||||
verdict: "clean",
|
||||
@@ -103,6 +104,15 @@ function makeCtx() {
|
||||
switch (id) {
|
||||
case "skillVersions:1":
|
||||
return latestVersion;
|
||||
case "publishers:local":
|
||||
return {
|
||||
_id: "publishers:local",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "local-publisher",
|
||||
displayName: "Local Dev",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
case "users:owner":
|
||||
return {
|
||||
_id: "users:owner",
|
||||
@@ -150,7 +160,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
});
|
||||
|
||||
it("returns reviewer info and recent audit logs with actor handles", async () => {
|
||||
it("returns publisher-backed owner info plus recent audit logs with actor handles", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
@@ -162,6 +172,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
slug: "padel",
|
||||
auditLogLimit: 5,
|
||||
})) as {
|
||||
owner: { handle?: string | null } | null;
|
||||
overrideReviewer: { handle?: string | null } | null;
|
||||
auditLogs: Array<{
|
||||
actor: { handle?: string | null } | null;
|
||||
@@ -171,6 +182,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
|
||||
expect(getSkillBadgeMap).toHaveBeenCalled();
|
||||
expect(auditTake).toHaveBeenCalledWith(5);
|
||||
expect(result.owner?.handle).toBe("local-publisher");
|
||||
expect(result.overrideReviewer?.handle).toBe("moddy");
|
||||
expect(result.auditLogs).toHaveLength(2);
|
||||
expect(result.auditLogs[0]?.action).toBe("skill.manual_override.set");
|
||||
|
||||
+22
-8
@@ -1632,7 +1632,11 @@ export const getBySlugForStaff = query({
|
||||
if (!skill) return null;
|
||||
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const owner = toPublicPublisher(ownerPublisher);
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id);
|
||||
const rawAuditLogs = await ctx.db
|
||||
.query("auditLogs")
|
||||
@@ -1659,10 +1663,20 @@ export const getBySlugForStaff = query({
|
||||
}));
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
|
||||
const forkOfOwner = forkOfSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: forkOfSkill.ownerPublisherId,
|
||||
ownerUserId: forkOfSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
|
||||
const canonicalOwner = canonicalSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: canonicalSkill.ownerPublisherId,
|
||||
ownerUserId: canonicalSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
requestedSlug: resolved.requestedSlug,
|
||||
@@ -1681,8 +1695,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
handle: forkOfOwner?.handle ?? null,
|
||||
userId: forkOfOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1693,8 +1707,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
handle: canonicalOwner?.handle ?? null,
|
||||
userId: canonicalOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -4519,7 +4533,7 @@ export const publishVersion: ReturnType<typeof action> = action({
|
||||
throw new ConvexError("MIT-0 license terms must be accepted to publish skills");
|
||||
}
|
||||
const { userId } = await requireUserFromAction(ctx);
|
||||
const target = (await ctx.runQuery(internal.publishers.resolvePublishTargetForUserInternal, {
|
||||
const target = (await ctx.runMutation(internal.publishers.resolvePublishTargetForUserInternal, {
|
||||
actorUserId: userId,
|
||||
ownerHandle: args.ownerHandle,
|
||||
minimumRole: "publisher",
|
||||
|
||||
@@ -18,6 +18,7 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
ensureHandler,
|
||||
getByHandle,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
@@ -32,6 +33,9 @@ type WrappedHandler<TArgs, TResult> = {
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
const getByHandleHandler = (
|
||||
getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>
|
||||
)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -500,6 +504,168 @@ describe("me", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.getByHandle", () => {
|
||||
it("normalizes the incoming handle before querying", async () => {
|
||||
const unique = vi.fn(async () => ({
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jaredforreal",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
}));
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "users") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected index ${name}`);
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
expect(handle).toBe("jaredforreal");
|
||||
return { unique };
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{ handle: " @JaredForReal " },
|
||||
);
|
||||
|
||||
expect(unique).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the linked user for a personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async (id: string) =>
|
||||
id === "users:owner"
|
||||
? {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: "Profile",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).toHaveBeenCalledWith("users:owner");
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
bio: "Profile",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve a deleted personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async () => {
|
||||
throw new Error("linked user should not be loaded for inactive publishers");
|
||||
});
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
it("keeps a derived handle unchanged when the new login is reserved", async () => {
|
||||
const { ctx, get, patch, query } = makeCtx();
|
||||
|
||||
+8
-12
@@ -6,7 +6,12 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
@@ -36,12 +41,7 @@ export const getByIdInternal = internalQuery({
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -396,11 +396,7 @@ function clampInt(value: number, min: number, max: number) {
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", args.handle))
|
||||
.unique();
|
||||
return toPublicUser(user);
|
||||
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+512
-2
@@ -1,5 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test } from "./vt";
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test, pollPackageReleaseScanResults, scanPackageReleaseWithVirusTotal } from "./vt";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const scanPackageReleaseWithVirusTotalHandler = (
|
||||
scanPackageReleaseWithVirusTotal as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const pollPackageReleaseScanResultsHandler = (
|
||||
pollPackageReleaseScanResults as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const originalVtApiKey = process.env.VT_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVtApiKey === undefined) {
|
||||
delete process.env.VT_API_KEY;
|
||||
} else {
|
||||
process.env.VT_API_KEY = originalVtApiKey;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("vt activation fallback", () => {
|
||||
it("activates only VT-pending hidden skills", () => {
|
||||
@@ -100,3 +132,481 @@ describe("vt AV engine fallback verdicts", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("package VT retries", () => {
|
||||
it("retries package scan when release files are not readable yet", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => null),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it("retries package upload when VT upload fails", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
sha256hash: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses existing AV engine verdicts for packages without re-uploading", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 1,
|
||||
harmless: 10,
|
||||
undetected: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({ status: "suspicious", source: "engines" }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes official source-linked packages with undetected-only VT stats via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes community source-linked packages with undetected-only VT stats via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries package poll when VT lookup throws", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error")));
|
||||
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during package polling", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during community package polling", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not promote undetected-only community packages without trusted verification", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "artifact-only" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+112
-40
@@ -12,6 +12,7 @@ const internalRefs = internal as unknown as {
|
||||
updateReleaseScanResultsInternal: unknown;
|
||||
};
|
||||
vt: {
|
||||
scanPackageReleaseWithVirusTotal: unknown;
|
||||
pollPackageReleaseScanResults: unknown;
|
||||
};
|
||||
};
|
||||
@@ -159,6 +160,70 @@ type VTFileResponse = {
|
||||
};
|
||||
|
||||
type VTAnalysisStats = NonNullable<VTFileResponse["data"]["attributes"]["last_analysis_stats"]>;
|
||||
type PackageReleaseScanDoc = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"verification" | "llmAnalysis" | "staticScan"
|
||||
>;
|
||||
type PackageScanDoc = Pick<Doc<"packages">, "family" | "isOfficial">;
|
||||
|
||||
function buildPackageUndetectedFallbackAnalysis(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
stats?: VTAnalysisStats,
|
||||
) {
|
||||
if (!stats) return null;
|
||||
if (pkg.family === "skill") return null;
|
||||
|
||||
const tier = release.verification?.tier;
|
||||
if (tier !== "source-linked" && tier !== "provenance-verified" && tier !== "rebuild-verified") {
|
||||
return null;
|
||||
}
|
||||
if (release.llmAnalysis?.status !== "clean") return null;
|
||||
if (!release.staticScan || release.staticScan.status === "malicious") return null;
|
||||
if (stats.malicious !== 0 || stats.suspicious !== 0) return null;
|
||||
if ((stats.harmless ?? 0) <= 0 && (stats.undetected ?? 0) <= 0) return null;
|
||||
|
||||
return {
|
||||
status: "clean",
|
||||
verdict: "undetected-only-fallback",
|
||||
analysis:
|
||||
"VirusTotal reported no malicious or suspicious engine hits. ClawHub promoted this source-linked package after clean LLM and non-malicious static scans.",
|
||||
source: "engines-undetected-fallback",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPackageScanAnalysisFromVtResult(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
vtResult: VTFileResponse,
|
||||
) {
|
||||
const aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
return {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
const status = statusFromAvStats(stats);
|
||||
if (status) {
|
||||
return {
|
||||
status,
|
||||
source: "engines",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return buildPackageUndetectedFallbackAnalysis(release, pkg, stats);
|
||||
}
|
||||
|
||||
type ScanQueueHealth = {
|
||||
queueSize: number;
|
||||
@@ -526,6 +591,7 @@ const PACKAGE_SCAN_MAX_ATTEMPTS = 10;
|
||||
export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
attempt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY;
|
||||
@@ -550,17 +616,30 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
let missingFiles = 0;
|
||||
for (const file of release.files) {
|
||||
const content = await ctx.storage.get(file.storageId);
|
||||
if (!content) continue;
|
||||
if (!content) {
|
||||
missingFiles += 1;
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
path: file.path,
|
||||
bytes: new Uint8Array(await content.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.warn(`[vt:package] No files found for release ${args.releaseId}, skipping scan`);
|
||||
if (entries.length === 0 || missingFiles > 0) {
|
||||
console.warn(
|
||||
`[vt:package] Release ${args.releaseId} missing ${missingFiles}/${release.files.length} files, retrying`,
|
||||
);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,21 +656,14 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
|
||||
try {
|
||||
const existingFile = await checkExistingFile(apiKey, sha256hash);
|
||||
const aiResult = existingFile?.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
const vtAnalysis = existingFile
|
||||
? buildPackageScanAnalysisFromVtResult(release, pkg, existingFile)
|
||||
: null;
|
||||
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -613,6 +685,12 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("[vt:package] VirusTotal upload error:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +704,12 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[vt:package] Failed to upload to VirusTotal:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -643,6 +727,10 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt || !release.sha256hash) return;
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg || pkg.softDeletedAt) return;
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
try {
|
||||
@@ -657,33 +745,11 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const vtAnalysis = buildPackageScanAnalysisFromVtResult(release, pkg, vtResult);
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = statusFromAvStats(vtResult.data.attributes.last_analysis_stats);
|
||||
if (status) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source: "engines",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -697,6 +763,12 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
summary: "Marketplace policy: what ClawHub will not allow."
|
||||
read_when:
|
||||
- Reviewing uploads for abuse or policy violations
|
||||
- Writing moderation docs or reviewer runbooks
|
||||
- Deciding whether a skill should be hidden or a user banned
|
||||
---
|
||||
|
||||
# Acceptable Usage
|
||||
|
||||
This page describes the kinds of skills and content ClawHub is not okay with.
|
||||
|
||||
These rules are intentionally practical. We care most about end-to-end abuse workflows, not just isolated keywords. If a skill is built to evade defenses, abuse platforms, scam people, invade privacy, or enable non-consensual behavior, it does not belong on ClawHub.
|
||||
|
||||
## Not okay
|
||||
|
||||
- Security-bypass or unauthorized-access workflows.
|
||||
- Examples: auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, stealth scraping designed to defeat protections, live call or agent takeover, reusable session theft, auto-approving pairing flows for unapproved users.
|
||||
|
||||
- Platform abuse and ban evasion.
|
||||
- Examples: stealth accounts after bans, account warming/farming, fake engagement, karma or follower cultivation, multi-account automation, mass posting, spam bots, marketplace or social automation built to avoid detection.
|
||||
|
||||
- Fraud, scams, and deceptive financial workflows.
|
||||
- Examples: fake certificates, fake invoices, deceptive payment flows, scam outreach, fake social proof, tools that enable spending or charging without clear human approval and transparent controls, or synthetic-identity workflows built to create accounts for fraud.
|
||||
|
||||
- Privacy-invasive scraping, enrichment, or surveillance.
|
||||
- Examples: scraping contact details at scale for spam, doxxing, stalking, lead extraction paired with unsolicited outreach, covert monitoring, face search or biometric matching used without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.
|
||||
|
||||
- Non-consensual impersonation or deceptive identity manipulation.
|
||||
- Examples: face swap, digital twins, fake personas, cloned influencers, or other identity-manipulation tooling used to impersonate or mislead.
|
||||
|
||||
- Explicit sexual content and safety-disabled adult generation.
|
||||
- Examples: NSFW image/video/content generation, adult-content wrappers around third-party APIs, or skills whose primary purpose is explicit sexual content.
|
||||
|
||||
- Hidden, unsafe, or misleading execution requirements.
|
||||
- Examples: obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, remote `npx @latest` execution without clear reviewability, misleading metadata that hides what the skill really needs to run.
|
||||
|
||||
## Recent patterns we are explicitly not okay with
|
||||
|
||||
- “Create stealth seller accounts after marketplace bans.”
|
||||
- “Modify Telegram pairing so unapproved users automatically receive pairing codes.”
|
||||
- “Cultivate Reddit/Twitter accounts with undetectable automation.”
|
||||
- “Generate professional certificates or invoices for arbitrary use.”
|
||||
- “Generate NSFW content with safety checks disabled.”
|
||||
- “Scrape leads, enrich contacts, and launch cold outreach at scale.”
|
||||
- “Buy, publish, or download leaked data or breach dumps.”
|
||||
- “Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.”
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
- Context matters. The same topic can be legitimate in a narrow defensive or consent-based setting and unacceptable when packaged as an abuse workflow.
|
||||
- We should bias toward action when a skill is clearly optimized for evasion, deception, or non-consensual use.
|
||||
- Repeated uploads in these categories are grounds for hiding content and banning the account.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- We may hide, remove, or hard-delete violating skills.
|
||||
- We may revoke tokens, soft-delete associated content, and ban repeat or severe offenders.
|
||||
- We do not guarantee warning-first enforcement for obvious abuse.
|
||||
+2
-2
@@ -24,8 +24,8 @@ Auth-aware enforcement:
|
||||
- Authenticated requests (valid Bearer token): per user bucket.
|
||||
- Missing/invalid token falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
|
||||
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` (on 429).
|
||||
|
||||
|
||||
+15
-12
@@ -21,9 +21,9 @@ Enforcement model:
|
||||
- Authenticated requests (valid Bearer token): enforced per user bucket.
|
||||
- If token is missing/invalid, behavior falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Download: 20/min per IP, 120/min per key (`/api/v1/download`)
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
- Download: 30/min per IP, 180/min per key (`/api/v1/download`)
|
||||
|
||||
Headers:
|
||||
|
||||
@@ -280,8 +280,8 @@ Notes:
|
||||
- Skill entries stay backed by the skill registry and can still be published only through `POST /api/v1/skills`.
|
||||
- `POST /api/v1/packages` is still only for code-plugin and bundle-plugin releases.
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can see their own private packages in list/search results.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can see private packages for publishers they belong to in list/search results.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/search`
|
||||
|
||||
@@ -300,8 +300,8 @@ Query params:
|
||||
Notes:
|
||||
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can search their own private packages.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can search private packages for publishers they belong to.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/{name}`
|
||||
|
||||
@@ -310,7 +310,7 @@ Returns package detail metadata.
|
||||
Notes:
|
||||
|
||||
- Skills can also resolve through this route in the unified catalog.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions`
|
||||
|
||||
@@ -323,15 +323,16 @@ Query params:
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions/{version}`
|
||||
|
||||
Returns one package version, including file metadata, compatibility, capabilities, and verification.
|
||||
Returns one package version, including file metadata, compatibility, capabilities, verification, and scan data.
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- `version.sha256hash`, `version.vtAnalysis`, `version.llmAnalysis`, and `version.staticScan` are included when scan data exists.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/file`
|
||||
|
||||
@@ -349,7 +350,8 @@ Notes:
|
||||
- Uses the read rate bucket, not the download bucket.
|
||||
- Binary files return `415`.
|
||||
- File size limit: 200KB.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Pending VirusTotal scans do not block reads; malicious releases may still be withheld elsewhere.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/download`
|
||||
|
||||
@@ -366,6 +368,7 @@ Notes:
|
||||
- Skills redirect to `GET /api/v1/download`.
|
||||
- Plugin/package archives are zip files with a `package/` root so they install directly in OpenClaw without repacking.
|
||||
- Registry-only metadata is not injected into the downloaded archive.
|
||||
- Pending VirusTotal scans do not block downloads; malicious releases return `403`.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
|
||||
### `GET /api/v1/resolve`
|
||||
|
||||
@@ -8,6 +8,8 @@ read_when:
|
||||
|
||||
# Security + Moderation
|
||||
|
||||
See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace policy on prohibited skill categories.
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills/comments.
|
||||
@@ -42,6 +44,11 @@ read_when:
|
||||
## Skill moderation pipeline
|
||||
|
||||
- New skill publishes now persist a deterministic static scan result on the version.
|
||||
- Package/plugin scan backfills now also recompute deterministic static scan results for older releases,
|
||||
so legacy plugin versions can surface OpenClaw scan findings without republishing.
|
||||
- Source-linked packages can fall back to a clean package verdict when VirusTotal only returns
|
||||
undetected engine results, provided the LLM scan is clean and static scan is non-malicious. This
|
||||
avoids indefinite pending scans when VT Code Insight never materializes.
|
||||
- Skill moderation state stores a structured snapshot:
|
||||
- `moderationVerdict`: `clean | suspicious | malicious`
|
||||
- `moderationReasonCodes[]`: canonical machine-readable reasons
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
const navLabels = ["Skills", "Upload", "Import", "Search"];
|
||||
const navLabels = ["Skills", "Plugins", "Search"];
|
||||
|
||||
test("skills loads without error", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
@@ -31,18 +31,9 @@ test("header menu routes render", async ({ page }) => {
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Upload") {
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
const heading = page.locator("h1.section-title", { hasText: /^Publish a /i });
|
||||
const signInCard = page.locator("text=Sign in to upload");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Import") {
|
||||
await expect(page).toHaveURL(/\/import/);
|
||||
const heading = page.getByRole("heading", { name: "Import from GitHub" });
|
||||
const signInCard = page.locator("text=Sign in to import and publish skills.");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
if (label === "Plugins") {
|
||||
await expect(page).toHaveURL(/\/plugins(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Search") {
|
||||
|
||||
@@ -57,6 +57,57 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export type PackageLlmAnalysisDimension =
|
||||
(typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -159,6 +210,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
Vendored
+2
-4
@@ -1,7 +1,5 @@
|
||||
import { type inferred } from "arktype";
|
||||
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
|
||||
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
|
||||
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
|
||||
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
|
||||
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL } from "./licenseConstants.js";
|
||||
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
|
||||
export declare const SkillPlatformLicenseSchema: import("arktype/internal/variants/string.ts").StringType<"MIT-0", {}>;
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
|
||||
|
||||
Vendored
+2
-4
@@ -1,7 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
export const PLATFORM_SKILL_LICENSE = "MIT-0";
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution";
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY = "Free to use, modify, and redistribute. No attribution required.";
|
||||
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html";
|
||||
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, } from "./licenseConstants.js";
|
||||
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"');
|
||||
//# sourceMappingURL=license.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC;AAE1F,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
|
||||
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,CAAC;AAEF,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
|
||||
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
|
||||
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
|
||||
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0';
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution';
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY = 'Free to use, modify, and redistribute. No attribution required.';
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html';
|
||||
//# sourceMappingURL=licenseConstants.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"licenseConstants.js","sourceRoot":"","sources":["../src/licenseConstants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC"}
|
||||
Vendored
+96
@@ -46,6 +46,63 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
}, {}>;
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisDimensionSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysisDimension = (typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
export declare const PackageStaticFindingSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}, {}>;
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
export declare const PackageStaticScanSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
}, {}>;
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
export declare const BundlePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
id?: string | undefined;
|
||||
format?: string | undefined;
|
||||
@@ -255,6 +312,45 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
sha256hash?: string | undefined;
|
||||
vtAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
} | null | undefined;
|
||||
llmAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
} | null | undefined;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+44
@@ -40,6 +40,46 @@ export const PackageVerificationSummarySchema = type({
|
||||
hasProvenance: "boolean?",
|
||||
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
|
||||
});
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -132,6 +172,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const ApiV1PackagePublishResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -15,6 +15,7 @@ export declare const ApiRoutes: {
|
||||
readonly resolve: "/api/v1/resolve";
|
||||
readonly download: "/api/v1/download";
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly plugins: "/api/v1/plugins";
|
||||
readonly packages: "/api/v1/packages";
|
||||
readonly codePlugins: "/api/v1/code-plugins";
|
||||
readonly bundlePlugins: "/api/v1/bundle-plugins";
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ export const ApiRoutes = {
|
||||
resolve: "/api/v1/resolve",
|
||||
download: "/api/v1/download",
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
@@ -11,6 +11,18 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./licenseConstants": {
|
||||
"types": "./dist/licenseConstants.d.ts",
|
||||
"default": "./dist/licenseConstants.js"
|
||||
},
|
||||
"./routes": {
|
||||
"types": "./dist/routes.d.ts",
|
||||
"default": "./dist/routes.js"
|
||||
},
|
||||
"./textFiles": {
|
||||
"types": "./dist/textFiles.d.ts",
|
||||
"default": "./dist/textFiles.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { type inferred, type } from "arktype";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "./licenseConstants.js";
|
||||
|
||||
export const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution" as const;
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY =
|
||||
"Free to use, modify, and redistribute. No attribution required." as const;
|
||||
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html" as const;
|
||||
export {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
};
|
||||
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"');
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY =
|
||||
'Free to use, modify, and redistribute. No attribution required.' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html' as const;
|
||||
@@ -57,6 +57,57 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export type PackageLlmAnalysisDimension =
|
||||
(typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -159,6 +210,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ApiRoutes = {
|
||||
resolve: "/api/v1/resolve",
|
||||
download: "/api/v1/download",
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import { ImportGitHub } from "../routes/import";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => config,
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PackageDetailResponse, PackageVersionDetail } from "../lib/packageApi";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
};
|
||||
|
||||
let paramsMock = { name: "demo-plugin" };
|
||||
let loaderDataMock = {
|
||||
let loaderDataMock: PluginDetailLoaderData = {
|
||||
detail: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
@@ -95,4 +102,56 @@ describe("plugin detail route", () => {
|
||||
expect(screen.getByText("No latest tag")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "Download zip" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders package security scan results when scan data is present", async () => {
|
||||
loaderDataMock = {
|
||||
detail: loaderDataMock.detail,
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: { tier: "source-linked", scope: "artifact-only", scanStatus: "clean" },
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
checkedAt: 1,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
summary: "Looks safe.",
|
||||
checkedAt: 1,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "1",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
};
|
||||
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Security Scan")).toBeTruthy();
|
||||
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,14 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
(path: string) =>
|
||||
(config: { component: unknown }) => ({ __config: config, __path: path }),
|
||||
useSearch: () => ({
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const generateUploadUrl = vi.fn();
|
||||
@@ -23,7 +31,7 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { Route } from "../routes/plugins/new";
|
||||
import { Route } from "../routes/publish-plugin";
|
||||
|
||||
function renderPublishRoute() {
|
||||
const route = Route as unknown as {
|
||||
@@ -75,12 +83,23 @@ describe("plugins publish route", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("registers the publish form on /plugins/new", () => {
|
||||
it("registers the publish form on /publish-plugin", () => {
|
||||
const route = Route as unknown as {
|
||||
__path: string;
|
||||
};
|
||||
|
||||
expect(route.__path).toBe("/plugins/new");
|
||||
expect(route.__path).toBe("/publish-plugin");
|
||||
});
|
||||
|
||||
it("keeps metadata inputs locked until plugin code is uploaded", () => {
|
||||
renderPublishRoute();
|
||||
|
||||
expect(screen.getByText(/Upload plugin code to detect the package shape/i)).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Plugin name").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Display name").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Version").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Changelog").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a code plugin folder with source metadata and normalized file paths", async () => {
|
||||
@@ -93,6 +112,7 @@ describe("plugins publish route", () => {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
@@ -115,14 +135,13 @@ describe("plugins publish route", () => {
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("1.2.3")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("openclaw/demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Plugin name").getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Initial release" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source repo (owner/repo)"), {
|
||||
target: { value: "openclaw/demo-plugin" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source commit"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
@@ -165,10 +184,6 @@ describe("plugins publish route", () => {
|
||||
it("publishes a bundle plugin folder with bundle metadata", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
fireEvent.change(screen.getAllByRole("combobox")[0], {
|
||||
target: { value: "bundle-plugin" },
|
||||
});
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
@@ -176,6 +191,10 @@ describe("plugins publish route", () => {
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
version: "0.4.0",
|
||||
openclaw: {
|
||||
bundleFormat: "openclaw-bundle",
|
||||
hostTargets: ["desktop", "mobile"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
@@ -198,17 +217,16 @@ describe("plugins publish route", () => {
|
||||
expect(screen.getByDisplayValue("demo-bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Demo Bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("0.4.0")).toBeTruthy();
|
||||
expect((screen.getAllByRole("combobox")[0] as HTMLSelectElement).value).toBe("bundle-plugin");
|
||||
expect(screen.getByDisplayValue("openclaw-bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("desktop, mobile")).toBeTruthy();
|
||||
expect(screen.getByText(/Browse files/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Choose folder/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Bundle release" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Bundle format"), {
|
||||
target: { value: "openclaw-bundle" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Host targets (comma separated)"), {
|
||||
target: { value: "desktop, mobile" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
@@ -237,6 +255,49 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefills metadata from a wrapped GitHub release package", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
name: "@opik/opik-openclaw",
|
||||
version: "0.2.9",
|
||||
repository: {
|
||||
type: "git",
|
||||
url: "https://github.com/comet-ml/opik-openclaw.git",
|
||||
},
|
||||
}),
|
||||
],
|
||||
"opik-openclaw-0.2.9/package.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const manifest = new File(
|
||||
[JSON.stringify({ id: "opik-openclaw", name: "Opik" })],
|
||||
"opik-openclaw-0.2.9/openclaw.plugin.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const readme = new File(
|
||||
["# Opik OpenClaw\n"],
|
||||
"opik-openclaw-0.2.9/README.md",
|
||||
{ type: "text/markdown" },
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("@opik/opik-openclaw")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Opik")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("0.2.9")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("comet-ml/opik-openclaw")).toBeTruthy();
|
||||
expect(screen.getByText(/Metadata detected and prefilled/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Autofilled package type, plugin name, display name, version, source repo\./i)).toBeTruthy();
|
||||
expect(screen.getByText("Package manifest")).toBeTruthy();
|
||||
expect(screen.getByText("Plugin manifest")).toBeTruthy();
|
||||
expect(screen.queryByText("opik-openclaw-0.2.9/package.json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies ignore rules before uploading a plugin folder", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
@@ -272,7 +333,7 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Ignored 1 files via ignore rules\./)).toBeTruthy();
|
||||
expect(screen.getByText(/Ignored 1 files/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
|
||||
@@ -7,6 +7,7 @@ const navigateMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
@@ -258,6 +259,104 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not redirect when a staff owner handle only differs by case", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:staff", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
if (args && typeof args === "object" && "slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "SteiPete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="weather"
|
||||
canonicalOwner="steipete"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
files: [],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# Weather",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens report dialog for authenticated users", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Upload } from "../routes/upload";
|
||||
import { Upload } from "../routes/publish-skill";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => config,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAuthErrorSnapshot, clearAuthError } from "../lib/useAuthError";
|
||||
import { AuthCodeHandler } from "./AppProviders";
|
||||
import { AuthCodeHandler, AuthErrorHandler } from "./AppProviders";
|
||||
|
||||
const signInMock = vi.fn();
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
}));
|
||||
|
||||
vi.mock("./UserBootstrap", () => ({
|
||||
UserBootstrap: () => null,
|
||||
}));
|
||||
|
||||
describe("AuthCodeHandler", () => {
|
||||
beforeEach(() => {
|
||||
signInMock.mockReset();
|
||||
@@ -68,3 +72,67 @@ describe("AuthCodeHandler", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthErrorHandler", () => {
|
||||
beforeEach(() => {
|
||||
signInMock.mockReset();
|
||||
clearAuthError();
|
||||
window.history.replaceState(null, "", "/sign-in");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearAuthError();
|
||||
});
|
||||
|
||||
it("does nothing when there is no auth error in the URL", () => {
|
||||
render(<AuthErrorHandler />);
|
||||
|
||||
expect(getAuthErrorSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces provider errors from the URL and strips them", async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/sign-in?error=access_denied&error_description=Account%20banned&next=%2Fdashboard#section",
|
||||
);
|
||||
|
||||
render(<AuthErrorHandler />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAuthErrorSnapshot()).toBe("Account banned");
|
||||
});
|
||||
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
"/sign-in?next=%2Fdashboard#section",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the provider error when there is no description", async () => {
|
||||
window.history.replaceState(null, "", "/sign-in?error=access_denied");
|
||||
|
||||
render(<AuthErrorHandler />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAuthErrorSnapshot()).toBe("access_denied");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the provider error when the description is blank", async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
"/sign-in?error=access_denied&error_description=%20%20%20",
|
||||
);
|
||||
|
||||
render(<AuthErrorHandler />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAuthErrorSnapshot()).toBe("access_denied");
|
||||
});
|
||||
|
||||
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
|
||||
"/sign-in",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,10 +48,40 @@ export function AuthCodeHandler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPendingAuthError() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const url = new URL(window.location.href);
|
||||
const description =
|
||||
url.searchParams.get("error_description")?.trim() || url.searchParams.get("error")?.trim();
|
||||
if (!description) return null;
|
||||
url.searchParams.delete("error");
|
||||
url.searchParams.delete("error_description");
|
||||
return {
|
||||
description,
|
||||
relativeUrl: `${url.pathname}${url.search}${url.hash}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthErrorHandler() {
|
||||
const handledErrorRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const pending = getPendingAuthError();
|
||||
if (!pending) return;
|
||||
if (handledErrorRef.current === pending.description) return;
|
||||
handledErrorRef.current = pending.description;
|
||||
|
||||
window.history.replaceState(null, "", pending.relativeUrl);
|
||||
setAuthError(pending.description);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
</ConvexAuthProvider>
|
||||
|
||||
@@ -94,10 +94,6 @@ export default function Header() {
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : <Link to="/plugins">Plugins</Link>}
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
{isSoulMode ? null : <Link to="/import">Import</Link>}
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
search={
|
||||
@@ -122,6 +118,7 @@ export default function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
{isSoulMode ? null : <Link to="/about">About</Link>}
|
||||
{me ? <Link to="/stars">Stars</Link> : null}
|
||||
{isStaff ? (
|
||||
<Link to="/management" search={{ skill: undefined }}>
|
||||
@@ -179,16 +176,6 @@ export default function Header() {
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/import">Import</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
@@ -215,6 +202,11 @@ export default function Header() {
|
||||
Search
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about">About</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
|
||||
@@ -144,12 +144,14 @@ export function SkillDetailPage({
|
||||
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
|
||||
|
||||
const ownerHandle = owner?.handle ?? null;
|
||||
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null);
|
||||
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
|
||||
const canonicalOwnerParam =
|
||||
typeof canonicalOwner === "string" ? canonicalOwner.trim().toLowerCase() : null;
|
||||
const wantsCanonicalRedirect = Boolean(
|
||||
ownerParam &&
|
||||
((result?.resolvedSlug && result.resolvedSlug !== slug) ||
|
||||
redirectToCanonical ||
|
||||
(typeof canonicalOwner === "string" && canonicalOwner && canonicalOwner !== ownerParam)),
|
||||
(canonicalOwnerParam && canonicalOwnerParam !== ownerParam)),
|
||||
);
|
||||
|
||||
const forkOf = result?.forkOf ?? null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
type ClawdisSkillMetadata,
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema";
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Package } from "lucide-react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import {
|
||||
type ClawdisSkillMetadata,
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "clawhub-schema";
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
|
||||
|
||||
type SkillInstallCardProps = {
|
||||
|
||||
+93
-43
@@ -126,26 +126,23 @@ describe("fetchPackages", () => {
|
||||
expect(url.searchParams.get("limit")).toBe("7");
|
||||
});
|
||||
|
||||
it("falls back across supported README variants", async () => {
|
||||
it("requests README through the canonical package file path once", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response("missing", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("lowercase readme", { status: 200 }));
|
||||
.mockResolvedValue(new Response("lowercase readme", { status: 200 }));
|
||||
|
||||
const result = await fetchPackageReadme("demo-plugin", "1.0.0");
|
||||
|
||||
expect(result).toBe("lowercase readme");
|
||||
const firstRequest = fetchMock.mock.calls[0]?.[0];
|
||||
const secondRequest = fetchMock.mock.calls[1]?.[0];
|
||||
if (typeof firstRequest !== "string" || typeof secondRequest !== "string") {
|
||||
throw new Error("Expected fetch calls to use string URLs");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch call to use a string URL");
|
||||
}
|
||||
const first = new URL(firstRequest);
|
||||
const second = new URL(secondRequest);
|
||||
expect(first.searchParams.get("path")).toBe("README.md");
|
||||
expect(second.searchParams.get("path")).toBe("readme.md");
|
||||
expect(second.searchParams.get("version")).toBe("1.0.0");
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.searchParams.get("path")).toBe("README.md");
|
||||
expect(url.searchParams.get("version")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("returns an empty package detail payload on 404", async () => {
|
||||
@@ -161,7 +158,15 @@ describe("fetchPackages", () => {
|
||||
it("forwards request cookies and includes credentials for package detail fetches", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
getRequestUrlMock.mockReturnValue(new URL("https://app.example/packages/private-plugin"));
|
||||
getRequestHeadersMock.mockReturnValue(new Headers({ cookie: "session=abc" }));
|
||||
getRequestHeadersMock.mockReturnValue(
|
||||
new Headers({
|
||||
cookie: "session=abc",
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 198.51.100.2",
|
||||
"x-real-ip": "203.0.113.9",
|
||||
"fly-client-ip": "203.0.113.9",
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ package: null, owner: null }), { status: 200 }),
|
||||
);
|
||||
@@ -175,6 +180,10 @@ describe("fetchPackages", () => {
|
||||
headers: expect.objectContaining({
|
||||
Accept: "application/json",
|
||||
cookie: "session=abc",
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 198.51.100.2",
|
||||
"x-real-ip": "203.0.113.9",
|
||||
"fly-client-ip": "203.0.113.9",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -213,6 +222,54 @@ describe("fetchPackages", () => {
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://app.example/api/v1/bundle-plugins?limit=12");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins endpoint for mixed plugin browse", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }));
|
||||
|
||||
await fetchPluginCatalog({
|
||||
limit: 12,
|
||||
cursor: "pkgpage:test",
|
||||
isOfficial: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch to be called with a string URL");
|
||||
}
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.pathname).toBe("/api/v1/plugins");
|
||||
expect(url.searchParams.get("limit")).toBe("12");
|
||||
expect(url.searchParams.get("cursor")).toBe("pkgpage:test");
|
||||
expect(url.searchParams.get("isOfficial")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins search endpoint for mixed plugin search", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({ results: [] }), { status: 200 }));
|
||||
|
||||
await fetchPluginCatalog({
|
||||
q: "demo",
|
||||
limit: 8,
|
||||
executesCode: false,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch to be called with a string URL");
|
||||
}
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.pathname).toBe("/api/v1/plugins/search");
|
||||
expect(url.searchParams.get("q")).toBe("demo");
|
||||
expect(url.searchParams.get("limit")).toBe("8");
|
||||
expect(url.searchParams.get("executesCode")).toBe("false");
|
||||
});
|
||||
|
||||
it("throws package detail errors for non-404 failures", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
|
||||
@@ -247,7 +304,17 @@ describe("fetchPackages", () => {
|
||||
.mockResolvedValue(new Response("missing", { status: 404 }));
|
||||
|
||||
await expect(fetchPackageReadme("demo-plugin", "1.0.0")).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null when README access is blocked pending scan", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("pending scan", { status: 423 }));
|
||||
|
||||
await expect(fetchPackageReadme("demo-plugin", "1.0.0")).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when README fetch fails for reasons other than 404", async () => {
|
||||
@@ -279,36 +346,29 @@ describe("fetchPluginCatalog", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses code and bundle plugin endpoints for browse mode without touching the unified catalog", async () => {
|
||||
it("uses the dedicated plugins endpoint for browse mode without touching the unified catalog", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ items: [], nextCursor: "code:next" }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ items: [], nextCursor: "bundle:next" }), { status: 200 }),
|
||||
);
|
||||
.mockResolvedValue(new Response(JSON.stringify({ items: [], nextCursor: "plugins:next" }), { status: 200 }));
|
||||
|
||||
const result = await fetchPluginCatalog({
|
||||
isOfficial: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(result.nextCursor).toContain("plugcat:");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const urls = fetchMock.mock.calls.map(([requestUrl]) => new URL(requestUrl as string));
|
||||
expect(urls[0]?.pathname).toBe("/api/v1/code-plugins");
|
||||
expect(urls[1]?.pathname).toBe("/api/v1/bundle-plugins");
|
||||
expect(urls[0]?.searchParams.get("isOfficial")).toBe("true");
|
||||
expect(urls[1]?.searchParams.get("isOfficial")).toBe("true");
|
||||
expect(result.nextCursor).toBe("plugins:next");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
|
||||
expect(url.pathname).toBe("/api/v1/plugins");
|
||||
expect(url.searchParams.get("isOfficial")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses code and bundle plugin search endpoints for search mode", async () => {
|
||||
it("uses the dedicated plugins search endpoint for search mode", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
@@ -324,15 +384,6 @@ describe("fetchPluginCatalog", () => {
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
score: 4,
|
||||
package: {
|
||||
@@ -358,9 +409,8 @@ describe("fetchPluginCatalog", () => {
|
||||
|
||||
expect(result.nextCursor).toBeNull();
|
||||
expect(result.items.map((item) => item.name)).toEqual(["code-demo", "bundle-demo"]);
|
||||
const urls = fetchMock.mock.calls.map(([requestUrl]) => new URL(requestUrl as string));
|
||||
expect(urls[0]?.pathname).toBe("/api/v1/packages/search");
|
||||
expect(urls[0]?.searchParams.get("family")).toBe("code-plugin");
|
||||
expect(urls[1]?.searchParams.get("family")).toBe("bundle-plugin");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
|
||||
expect(url.pathname).toBe("/api/v1/plugins/search");
|
||||
});
|
||||
});
|
||||
|
||||
+76
-209
@@ -3,7 +3,7 @@ import type {
|
||||
PackageCompatibility,
|
||||
PackageVerificationSummary,
|
||||
} from "clawhub-schema";
|
||||
import { ApiRoutes } from "clawhub-schema";
|
||||
import { ApiRoutes } from "clawhub-schema/routes";
|
||||
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
|
||||
|
||||
export type PackageListItem = {
|
||||
@@ -68,84 +68,55 @@ export type PackageVersionDetail = {
|
||||
compatibility?: PackageCompatibility | null;
|
||||
capabilities?: PackageCapabilitySummary | null;
|
||||
verification?: PackageVerificationSummary | null;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
analysis?: string;
|
||||
source?: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
llmAnalysis?: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
confidence?: string;
|
||||
summary?: string;
|
||||
dimensions?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}>;
|
||||
guidance?: string;
|
||||
findings?: string;
|
||||
model?: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: Array<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}>;
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PluginFamily = "code-plugin" | "bundle-plugin";
|
||||
|
||||
type PluginCatalogSourceCursor = {
|
||||
cursor: string | null;
|
||||
offset: number;
|
||||
pageSize: number;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
type PluginCatalogCursorState = {
|
||||
code: PluginCatalogSourceCursor;
|
||||
bundle: PluginCatalogSourceCursor;
|
||||
};
|
||||
|
||||
type PluginCatalogResult = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_PLUGIN_SOURCE_CURSOR: PluginCatalogSourceCursor = {
|
||||
cursor: null,
|
||||
offset: 0,
|
||||
pageSize: 0,
|
||||
done: false,
|
||||
};
|
||||
|
||||
function clonePluginSourceCursor(
|
||||
source: Partial<PluginCatalogSourceCursor> | null | undefined,
|
||||
): PluginCatalogSourceCursor {
|
||||
return {
|
||||
cursor: typeof source?.cursor === "string" ? source.cursor : null,
|
||||
offset: typeof source?.offset === "number" && source.offset > 0 ? source.offset : 0,
|
||||
pageSize: typeof source?.pageSize === "number" && source.pageSize > 0 ? source.pageSize : 0,
|
||||
done: source?.done === true,
|
||||
};
|
||||
}
|
||||
|
||||
function encodePluginCatalogCursor(state: PluginCatalogCursorState) {
|
||||
return `plugcat:${encodeURIComponent(JSON.stringify(state))}`;
|
||||
}
|
||||
|
||||
function decodePluginCatalogCursor(cursor: string | undefined): PluginCatalogCursorState {
|
||||
if (!cursor?.startsWith("plugcat:")) {
|
||||
return {
|
||||
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
};
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(cursor.slice("plugcat:".length))) as {
|
||||
code?: Partial<PluginCatalogSourceCursor>;
|
||||
bundle?: Partial<PluginCatalogSourceCursor>;
|
||||
};
|
||||
return {
|
||||
code: clonePluginSourceCursor(decoded.code),
|
||||
bundle: clonePluginSourceCursor(decoded.bundle),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function comparePluginListItems(a: PackageListItem | undefined, b: PackageListItem | undefined) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return (
|
||||
b.updatedAt - a.updatedAt ||
|
||||
b.createdAt - a.createdAt ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeApiPath(path: string) {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
@@ -192,8 +163,18 @@ async function getForwardedHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const cookie = requestHeaders.get("cookie");
|
||||
const authorization = requestHeaders.get("authorization");
|
||||
const clientIpHeaders = [
|
||||
"cf-connecting-ip",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
"fly-client-ip",
|
||||
] as const;
|
||||
if (cookie) headers.cookie = cookie;
|
||||
if (authorization) headers.authorization = authorization;
|
||||
for (const headerName of clientIpHeaders) {
|
||||
const value = requestHeaders.get(headerName);
|
||||
if (value) headers[headerName] = value;
|
||||
}
|
||||
return headers;
|
||||
} catch {
|
||||
return {};
|
||||
@@ -285,144 +266,33 @@ export async function fetchPluginCatalog(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(params.limit ?? 25, 100));
|
||||
const families: PluginFamily[] = ["code-plugin", "bundle-plugin"];
|
||||
|
||||
if (params.q?.trim()) {
|
||||
const results = await Promise.all(
|
||||
families.map(async (family) => {
|
||||
const response = await fetchPackages({
|
||||
q: params.q,
|
||||
family,
|
||||
isOfficial: params.isOfficial,
|
||||
executesCode: params.executesCode,
|
||||
limit,
|
||||
});
|
||||
return "results" in response ? response.results : [];
|
||||
}),
|
||||
);
|
||||
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
|
||||
url.searchParams.set("q", params.q.trim());
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
const response = await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(url);
|
||||
return {
|
||||
items: results
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt ||
|
||||
a.package.name.localeCompare(b.package.name),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((entry) => entry.package),
|
||||
items: response.results.map((entry) => entry.package),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const decodedCursor = decodePluginCatalogCursor(params.cursor);
|
||||
const requests = await Promise.all(
|
||||
families.map(async (family) => {
|
||||
const source = family === "code-plugin" ? decodedCursor.code : decodedCursor.bundle;
|
||||
if (source.done && source.offset === 0) {
|
||||
return {
|
||||
family,
|
||||
source,
|
||||
items: [] as PackageListItem[],
|
||||
nextCursor: null,
|
||||
effectivePageSize: source.pageSize,
|
||||
pageCursor: source.cursor,
|
||||
isDone: true,
|
||||
};
|
||||
}
|
||||
const effectivePageSize =
|
||||
source.offset > 0 && source.pageSize
|
||||
? Math.max(source.pageSize, source.offset + 1)
|
||||
: Math.max(limit * 3, limit);
|
||||
const response = await fetchPackages({
|
||||
family,
|
||||
cursor: source.cursor ?? undefined,
|
||||
isOfficial: params.isOfficial,
|
||||
executesCode: params.executesCode,
|
||||
limit: effectivePageSize,
|
||||
});
|
||||
if ("results" in response) {
|
||||
throw new Error("Expected list response for plugin catalog browse");
|
||||
}
|
||||
return {
|
||||
family,
|
||||
source,
|
||||
items: response.items,
|
||||
nextCursor: response.nextCursor,
|
||||
effectivePageSize,
|
||||
pageCursor: source.cursor,
|
||||
isDone: response.nextCursor === null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const indexes: Record<PluginFamily, number> = {
|
||||
"code-plugin": decodedCursor.code.offset,
|
||||
"bundle-plugin": decodedCursor.bundle.offset,
|
||||
};
|
||||
const items: PackageListItem[] = [];
|
||||
|
||||
while (items.length < limit) {
|
||||
const codeRequest = requests.find((entry) => entry.family === "code-plugin");
|
||||
const bundleRequest = requests.find((entry) => entry.family === "bundle-plugin");
|
||||
const codeItem =
|
||||
codeRequest && indexes["code-plugin"] < codeRequest.items.length
|
||||
? codeRequest.items[indexes["code-plugin"]]
|
||||
: undefined;
|
||||
const bundleItem =
|
||||
bundleRequest && indexes["bundle-plugin"] < bundleRequest.items.length
|
||||
? bundleRequest.items[indexes["bundle-plugin"]]
|
||||
: undefined;
|
||||
if (!codeItem && !bundleItem) break;
|
||||
if (comparePluginListItems(codeItem, bundleItem) <= 0) {
|
||||
items.push(codeItem!);
|
||||
indexes["code-plugin"] += 1;
|
||||
} else {
|
||||
items.push(bundleItem!);
|
||||
indexes["bundle-plugin"] += 1;
|
||||
}
|
||||
const url = await packageApiUrl(ApiRoutes.plugins);
|
||||
if (params.cursor) url.searchParams.set("cursor", params.cursor);
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
|
||||
const nextState: PluginCatalogCursorState = {
|
||||
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
|
||||
};
|
||||
|
||||
for (const request of requests) {
|
||||
const nextOffset = indexes[request.family];
|
||||
const nextSource =
|
||||
nextOffset < request.items.length
|
||||
? {
|
||||
cursor: request.pageCursor,
|
||||
offset: nextOffset,
|
||||
pageSize: request.effectivePageSize,
|
||||
done: request.isDone,
|
||||
}
|
||||
: {
|
||||
cursor: request.nextCursor,
|
||||
offset: 0,
|
||||
pageSize: request.effectivePageSize,
|
||||
done: request.isDone,
|
||||
};
|
||||
if (request.family === "code-plugin") {
|
||||
nextState.code = nextSource;
|
||||
} else {
|
||||
nextState.bundle = nextSource;
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
|
||||
const isDone =
|
||||
nextState.code.done &&
|
||||
nextState.code.offset === 0 &&
|
||||
nextState.bundle.done &&
|
||||
nextState.bundle.offset === 0;
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor: isDone ? null : encodePluginCatalogCursor(nextState),
|
||||
};
|
||||
return await fetchJson<PluginCatalogResult>(url);
|
||||
}
|
||||
|
||||
export async function fetchPackageDetail(name: string) {
|
||||
@@ -446,14 +316,11 @@ export async function fetchPackageVersion(name: string, version: string) {
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null) {
|
||||
const variants = ["README.md", "readme.md", "README.mdx", "readme.mdx"];
|
||||
for (const path of variants) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", path);
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status !== 404) throw new Error(await response.text());
|
||||
}
|
||||
return null;
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ type UploadablePackageFile = {
|
||||
webkitRelativePath?: string;
|
||||
};
|
||||
|
||||
export type NormalizedPackageUploadFile<TFile extends UploadablePackageFile = UploadablePackageFile> = {
|
||||
file: TFile;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const KNOWN_PACKAGE_ROOT_PATHS = new Set([
|
||||
'package.json',
|
||||
'openclaw.plugin.json',
|
||||
@@ -67,14 +72,20 @@ function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(fi
|
||||
.some((path) => KNOWN_PACKAGE_ROOT_PATHS.has(path));
|
||||
}
|
||||
|
||||
export async function filterIgnoredPackageFiles<TFile extends UploadablePackageFile & Pick<File, "text">>(
|
||||
export function normalizePackageUploadFiles<TFile extends UploadablePackageFile>(
|
||||
files: TFile[],
|
||||
) {
|
||||
): NormalizedPackageUploadFile<TFile>[] {
|
||||
const stripTopLevelFolder = shouldStripSharedTopLevelFolder(files);
|
||||
const normalized = files.map((file) => ({
|
||||
return files.map((file) => ({
|
||||
file,
|
||||
path: getNormalizedUploadPath(file, { stripTopLevelFolder }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function filterIgnoredPackageFiles<TFile extends UploadablePackageFile & Pick<File, "text">>(
|
||||
files: TFile[],
|
||||
) {
|
||||
const normalized = normalizePackageUploadFiles(files);
|
||||
const ig = ignore();
|
||||
ig.add(DEFAULT_PACKAGE_IGNORE_PATTERNS);
|
||||
|
||||
@@ -111,13 +122,11 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
|
||||
sha256: string;
|
||||
contentType?: string;
|
||||
}> = [];
|
||||
const stripTopLevelFolder = shouldStripSharedTopLevelFolder(files);
|
||||
|
||||
for (const file of files) {
|
||||
for (const { file, path } of normalizePackageUploadFiles(files)) {
|
||||
const sha256 = await options.hashFile(file);
|
||||
const uploadUrl = await options.generateUploadUrl();
|
||||
const storageId = await options.uploadFile(uploadUrl, file);
|
||||
const path = getNormalizedUploadPath(file, { stripTopLevelFolder });
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
|
||||
import { TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
|
||||
import { gunzipSync, unzipSync } from "fflate";
|
||||
|
||||
const TEXT_TYPES = new Map([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
|
||||
import { getUserFacingConvexError } from "./convexError";
|
||||
|
||||
export async function uploadFile(uploadUrl: string, file: File) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useAuthStatus } from "./useAuthStatus";
|
||||
|
||||
const useConvexAuthMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useConvexAuth: () => useConvexAuthMock(),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
function Probe() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
return (
|
||||
<output>
|
||||
{JSON.stringify({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
me,
|
||||
})}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useAuthStatus", () => {
|
||||
it("does not keep auth loading true when only the profile query is unresolved", () => {
|
||||
useConvexAuthMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(screen.getByText('{"isAuthenticated":false,"isLoading":false}')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves authenticated session state before the profile query resolves", () => {
|
||||
useConvexAuthMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(screen.getByText('{"isAuthenticated":true,"isLoading":false}')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
|
||||
export function useAuthStatus() {
|
||||
const auth = useConvexAuth();
|
||||
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
|
||||
return {
|
||||
me,
|
||||
isLoading: me === undefined,
|
||||
isAuthenticated: Boolean(me),
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import { Route as UploadRouteImport } from './routes/upload'
|
||||
import { Route as StarsRouteImport } from './routes/stars'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as PublishSkillRouteImport } from './routes/publish-skill'
|
||||
import { Route as PublishPluginRouteImport } from './routes/publish-plugin'
|
||||
import { Route as ManagementRouteImport } from './routes/management'
|
||||
import { Route as ImportRouteImport } from './routes/import'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
|
||||
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
|
||||
@@ -52,6 +55,16 @@ const SearchRoute = SearchRouteImport.update({
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PublishSkillRoute = PublishSkillRouteImport.update({
|
||||
id: '/publish-skill',
|
||||
path: '/publish-skill',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PublishPluginRoute = PublishPluginRouteImport.update({
|
||||
id: '/publish-plugin',
|
||||
path: '/publish-plugin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ManagementRoute = ManagementRouteImport.update({
|
||||
id: '/management',
|
||||
path: '/management',
|
||||
@@ -72,6 +85,11 @@ const AdminRoute = AdminRouteImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -145,10 +163,13 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
@@ -169,10 +190,13 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
@@ -194,10 +218,13 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
@@ -220,10 +247,13 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
@@ -244,10 +274,13 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
@@ -268,10 +301,13 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
@@ -293,10 +329,13 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
ImportRoute: typeof ImportRoute
|
||||
ManagementRoute: typeof ManagementRoute
|
||||
PublishPluginRoute: typeof PublishPluginRoute
|
||||
PublishSkillRoute: typeof PublishSkillRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
StarsRoute: typeof StarsRoute
|
||||
@@ -346,6 +385,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/publish-skill': {
|
||||
id: '/publish-skill'
|
||||
path: '/publish-skill'
|
||||
fullPath: '/publish-skill'
|
||||
preLoaderRoute: typeof PublishSkillRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/publish-plugin': {
|
||||
id: '/publish-plugin'
|
||||
path: '/publish-plugin'
|
||||
fullPath: '/publish-plugin'
|
||||
preLoaderRoute: typeof PublishPluginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/management': {
|
||||
id: '/management'
|
||||
path: '/management'
|
||||
@@ -374,6 +427,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -477,10 +537,13 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
ImportRoute: ImportRoute,
|
||||
ManagementRoute: ManagementRoute,
|
||||
PublishPluginRoute: PublishPluginRoute,
|
||||
PublishSkillRoute: PublishSkillRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
StarsRoute: StarsRoute,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
|
||||
|
||||
const prohibitedCategories = [
|
||||
{
|
||||
title: 'Bypass and unauthorized access',
|
||||
examples:
|
||||
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
|
||||
},
|
||||
{
|
||||
title: 'Platform abuse and ban evasion',
|
||||
examples:
|
||||
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
|
||||
},
|
||||
{
|
||||
title: 'Fraud and deception',
|
||||
examples:
|
||||
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-invasive surveillance',
|
||||
examples:
|
||||
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
|
||||
},
|
||||
{
|
||||
title: 'Non-consensual impersonation',
|
||||
examples:
|
||||
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
|
||||
},
|
||||
{
|
||||
title: 'Explicit sexual content',
|
||||
examples:
|
||||
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
|
||||
},
|
||||
{
|
||||
title: 'Hidden or misleading execution',
|
||||
examples:
|
||||
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
|
||||
},
|
||||
];
|
||||
|
||||
const recentPatterns = [
|
||||
'Create stealth seller accounts after marketplace bans.',
|
||||
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
|
||||
'Cultivate Reddit or Twitter accounts with undetectable automation.',
|
||||
'Generate professional certificates or invoices for arbitrary use.',
|
||||
'Generate NSFW content with safety checks disabled.',
|
||||
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
|
||||
'Buy, publish, or download leaked data or breach dumps.',
|
||||
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
|
||||
];
|
||||
|
||||
export const Route = createFileRoute('/about')({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const title = `About · ${siteName}`;
|
||||
const description =
|
||||
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
|
||||
|
||||
return {
|
||||
links: [
|
||||
{
|
||||
rel: "canonical",
|
||||
href: `${siteUrl}/about`,
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: title },
|
||||
{ property: 'og:description', content: description },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${siteUrl}/about` },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: AboutPage,
|
||||
});
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<section className="card">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">About</span>
|
||||
<span className="tag tag-accent">Policy</span>
|
||||
</div>
|
||||
<h1 className="section-title" style={{ marginBottom: 10 }}>
|
||||
What ClawHub Will Not Host
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 14 }}>
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
|
||||
defenses, abuse platforms, scam people, invade privacy, or enable non-consensual
|
||||
behavior, it does not belong here.
|
||||
</p>
|
||||
<div className="stat">
|
||||
We moderate based on end-to-end abuse patterns, not just isolated keywords.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid" style={{ gap: 16 }}>
|
||||
{prohibitedCategories.map((category) => (
|
||||
<article key={category.title} className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 8 }}>
|
||||
{category.title}
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{category.examples}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 10 }}>
|
||||
Recent patterns we are explicitly not okay with
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="management-subitem">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 10 }}>
|
||||
Enforcement
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-card-tags" style={{ marginTop: 16 }}>
|
||||
<Link className="btn btn-primary" to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
<a
|
||||
className="btn"
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+359
-54
@@ -1,14 +1,67 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { Clock, Package, Plus, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Package,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
} as const;
|
||||
|
||||
type DashboardSkill = PublicSkill & { pendingReview?: boolean };
|
||||
|
||||
type DashboardPackage = {
|
||||
_id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
runtimeId?: string | null;
|
||||
sourceRepo?: string | null;
|
||||
summary?: string | null;
|
||||
latestVersion?: string | null;
|
||||
stats: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
};
|
||||
verification?: {
|
||||
tier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified";
|
||||
} | null;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
pendingReview?: boolean;
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: Dashboard,
|
||||
});
|
||||
@@ -27,7 +80,9 @@ function Dashboard() {
|
||||
}>
|
||||
| undefined;
|
||||
const [selectedPublisherId, setSelectedPublisherId] = useState<string>("");
|
||||
const selectedPublisher = publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null;
|
||||
const selectedPublisher =
|
||||
publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null;
|
||||
|
||||
const mySkills = useQuery(
|
||||
api.skills.list,
|
||||
selectedPublisher?.publisher.kind === "user" && me?._id
|
||||
@@ -35,9 +90,17 @@ function Dashboard() {
|
||||
: selectedPublisherId
|
||||
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 }
|
||||
: me?._id
|
||||
? { ownerUserId: me._id, limit: 100 }
|
||||
: "skip",
|
||||
) as DashboardSkill[] | undefined;
|
||||
const myPackages = useQuery(
|
||||
api.packages.list,
|
||||
selectedPublisherId
|
||||
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 }
|
||||
: me?._id
|
||||
? { ownerUserId: me._id, limit: 100 }
|
||||
: "skip",
|
||||
) as DashboardSkill[] | undefined;
|
||||
) as DashboardPackage[] | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPublisherId) return;
|
||||
@@ -56,6 +119,7 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
const skills = mySkills ?? [];
|
||||
const packages = myPackages ?? [];
|
||||
const ownerHandle =
|
||||
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
|
||||
@@ -64,57 +128,125 @@ function Dashboard() {
|
||||
<div className="dashboard-header">
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
Publisher Skills
|
||||
Publisher Dashboard
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
New skill versions stay private until automated security checks and verification finish.
|
||||
Owner-only view for skills and plugins, including security scans and verification.
|
||||
</p>
|
||||
</div>
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
{publishers.map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher._id}>
|
||||
@{entry.publisher.handle} · {entry.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Upload New Skill
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<div className="card dashboard-empty">
|
||||
<Package className="dashboard-empty-icon" aria-hidden="true" />
|
||||
<h2>No skills yet</h2>
|
||||
<p>Upload your first skill to share it with the community.</p>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
{publishers.map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher._id}>
|
||||
@{entry.publisher.handle} · {entry.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Upload a Skill
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-grid">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="card dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
</div>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
</div>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
return (
|
||||
<div className="dashboard-skill-card">
|
||||
<div className="dashboard-skill-info">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
@@ -122,7 +254,7 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-skill-slug">/{skill.slug}</span>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<span className="tag tag-pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
@@ -130,22 +262,36 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
|
||||
{skill.pendingReview ? (
|
||||
<p className="dashboard-skill-description">
|
||||
Hidden until VirusTotal and verification checks finish.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="dashboard-skill-stats">
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
|
||||
</span>
|
||||
<span>★ {formatCompactStat(skill.stats.stars)}</span>
|
||||
<span>{skill.stats.versions} v</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-skill-actions">
|
||||
<Link to="/upload" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
{skill.pendingReview ? (
|
||||
<>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
VT pending
|
||||
</span>
|
||||
<span className="dashboard-inline-status-note">
|
||||
Hidden until verification checks finish.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="dashboard-inline-status-note">Visible</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
@@ -160,3 +306,162 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scanStatusLabel(status: string | null | undefined) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending scan";
|
||||
case "clean":
|
||||
return "Scan clean";
|
||||
case "suspicious":
|
||||
return "Suspicious";
|
||||
case "malicious":
|
||||
return "Blocked";
|
||||
case "not-run":
|
||||
return "Scan not run";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseStatusLabel(
|
||||
label: string,
|
||||
status: string | null | undefined,
|
||||
emptyLabel = "not started",
|
||||
) {
|
||||
return `${label}: ${status?.trim() ? status : emptyLabel}`;
|
||||
}
|
||||
|
||||
function PackageStatusTag({
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "default" | "pending" | "warning" | "danger" | "success";
|
||||
}) {
|
||||
const className =
|
||||
tone === "pending"
|
||||
? "tag tag-pending"
|
||||
: tone === "warning"
|
||||
? "tag dashboard-tag-warning"
|
||||
: tone === "danger"
|
||||
? "tag dashboard-tag-danger"
|
||||
: tone === "success"
|
||||
? "tag dashboard-tag-success"
|
||||
: "tag";
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
: pkg.scanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.scanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.scanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
const staticTone =
|
||||
pkg.latestRelease?.staticScanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.latestRelease?.staticScanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.latestRelease?.staticScanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-list-id">{pkg.name}</span>
|
||||
</div>
|
||||
<div className="dashboard-inline-tags">
|
||||
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
|
||||
<PackageStatusTag label={pkg.channel} tone="default" />
|
||||
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
|
||||
{pkg.verification?.tier ? (
|
||||
<PackageStatusTag label={pkg.verification.tier} tone="default" />
|
||||
) : null}
|
||||
{pkg.latestRelease?.staticScanStatus ? (
|
||||
<PackageStatusTag
|
||||
label={`Static ${pkg.latestRelease.staticScanStatus}`}
|
||||
tone={staticTone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
|
||||
</span>
|
||||
<span>
|
||||
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
|
||||
</span>
|
||||
{pkg.runtimeId ? (
|
||||
<span>
|
||||
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<span>
|
||||
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel(
|
||||
"VT",
|
||||
pkg.latestRelease?.vtStatus,
|
||||
pkg.scanStatus === "pending" ? "pending" : "unknown",
|
||||
)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<CheckCircle2 size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<AlertTriangle size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
@@ -226,6 +226,23 @@ export function ImportGitHub() {
|
||||
<div className="upload-kicker">GitHub import</div>
|
||||
<h1 className="upload-title">Import from GitHub</h1>
|
||||
<p className="upload-subtitle">Public repos only. Detects SKILL.md automatically.</p>
|
||||
<div className="tag tag-accent" style={{ marginTop: 12, width: "fit-content" }}>
|
||||
Skill-only import. Plugins are not supported here. Use{" "}
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<div className="upload-badge">
|
||||
<div>Public only</div>
|
||||
|
||||
@@ -69,8 +69,8 @@ function SkillsHome() {
|
||||
vectors. No gatekeeping, just signal.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a skill
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
@@ -207,8 +207,8 @@ function OnlyCrabsHome() {
|
||||
public place.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a soul
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish Soul
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
|
||||
@@ -72,8 +72,11 @@ type SkillBySlugResult = {
|
||||
} | null;
|
||||
} | null;
|
||||
|
||||
function resolveOwnerParam(handle: string | null | undefined, ownerId?: Id<"users">) {
|
||||
return handle?.trim() || (ownerId ? String(ownerId) : "unknown");
|
||||
function resolveOwnerParam(
|
||||
handle: string | null | undefined,
|
||||
ownerId?: Id<"users"> | Id<"publishers">,
|
||||
) {
|
||||
return handle?.trim().toLowerCase() || (ownerId ? String(ownerId) : "unknown");
|
||||
}
|
||||
|
||||
function promptBanReason(label: string) {
|
||||
|
||||
@@ -2,6 +2,16 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/new")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/plugins/new" });
|
||||
throw redirect({
|
||||
to: "/publish-plugin",
|
||||
search: {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
@@ -19,12 +20,12 @@ type PluginDetailLoaderData = {
|
||||
|
||||
export const Route = createFileRoute("/plugins/$name")({
|
||||
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
|
||||
const readmePromise = fetchPackageReadme(params.name);
|
||||
const detail = await fetchPackageDetail(params.name);
|
||||
const version =
|
||||
detail.package?.latestVersion
|
||||
? await fetchPackageVersion(params.name, detail.package.latestVersion)
|
||||
: null;
|
||||
const readme = await fetchPackageReadme(params.name, detail.package?.latestVersion);
|
||||
const versionPromise = detail.package?.latestVersion
|
||||
? fetchPackageVersion(params.name, detail.package.latestVersion)
|
||||
: Promise.resolve(null);
|
||||
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
|
||||
return { detail, version, readme };
|
||||
},
|
||||
head: ({ params, loaderData }) => ({
|
||||
@@ -159,6 +160,14 @@ function PluginDetailRoute() {
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{latestRelease ? (
|
||||
<SecurityScanResults
|
||||
sha256hash={latestRelease.sha256hash ?? undefined}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
|
||||
staticFindings={latestRelease.staticScan?.findings ?? []}
|
||||
/>
|
||||
) : null}
|
||||
<details className="bundle-details" open>
|
||||
<summary>Verification</summary>
|
||||
<pre>
|
||||
|
||||
@@ -177,8 +177,19 @@ export function PluginsIndex() {
|
||||
>
|
||||
Executes code
|
||||
</button>
|
||||
<Link className="btn btn-primary" to="/plugins/new">
|
||||
Publish
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+18
-290
@@ -1,293 +1,21 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { startTransition, useEffect, useMemo, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../../convex/lib/publishLimits";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../../lib/uploadFiles";
|
||||
import { buildPackageUploadEntries, filterIgnoredPackageFiles } from "../../lib/packageUpload";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
import { formatBytes, formatPublishError, hashFile, uploadFile } from "../upload/-utils";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/plugins/new")({
|
||||
component: PublishPluginRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function PublishPluginRoute() {
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (
|
||||
args: { payload: unknown },
|
||||
) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin");
|
||||
const [name, setName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [ownerHandle, setOwnerHandle] = useState("");
|
||||
const [version, setVersion] = useState("0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const validationError =
|
||||
oversizedFiles.length > 0
|
||||
? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`
|
||||
: totalBytes > MAX_PUBLISH_TOTAL_BYTES
|
||||
? "Total file size exceeds 50MB."
|
||||
: null;
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
validateSearch: (search) => ({
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
name: typeof search.name === "string" ? search.name : undefined,
|
||||
displayName: typeof search.displayName === "string" ? search.displayName : undefined,
|
||||
family:
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/publish-plugin",
|
||||
search,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const nextIgnoredPaths = [...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths])];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
|
||||
const packageJson = filtered.files.find((file) => file.name.toLowerCase().endsWith("package.json"));
|
||||
if (!packageJson) return;
|
||||
try {
|
||||
const text = await packageJson.text();
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
if (typeof parsed.name === "string") setName(parsed.name);
|
||||
if (typeof parsed.displayName === "string") setDisplayName(parsed.displayName);
|
||||
if (typeof parsed.version === "string") setVersion(parsed.version);
|
||||
} catch {
|
||||
// ignore invalid package.json during form-prefill
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Publish Plugin
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Upload a native code plugin or bundle plugin release.
|
||||
</p>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
</p>
|
||||
</header>
|
||||
<div className="card" style={{ display: "grid", gap: 12 }}>
|
||||
{!isAuthenticated ? <div>Log in to publish plugins.</div> : null}
|
||||
<select className="input" value={family} onChange={(event) => setFamily(event.target.value as never)}>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
<option value="bundle-plugin">Bundle plugin</option>
|
||||
</select>
|
||||
<input className="input" placeholder="Plugin name" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<select className="input" value={ownerHandle} onChange={(event) => setOwnerHandle(event.target.value)}>
|
||||
{(publishers ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="input" placeholder="Version" value={version} onChange={(event) => setVersion(event.target.value)} />
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
multiple
|
||||
// @ts-expect-error non-standard directory picker
|
||||
webkitdirectory=""
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<div className="tag">{files.length} files · {formatBytes(totalBytes)}</div>
|
||||
{ignoredPaths.length > 0 ? <div className="tag">Ignored {ignoredPaths.length} files via ignore rules.</div> : null}
|
||||
{validationError ? <div className="tag tag-accent">{validationError}</div> : null}
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(validationError) ||
|
||||
Boolean(status) ||
|
||||
(family === "code-plugin" && (!sourceRepo.trim() || !sourceCommit.trim()))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release…");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus("Published. Pending security checks and verification before public listing.");
|
||||
} catch (publishError) {
|
||||
setError(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</button>
|
||||
{error ? <div className="tag tag-accent">{error}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
const dropped = await expandDroppedItems(event.dataTransfer.items);
|
||||
await onPickFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Drop a plugin folder, zip, or tgz here.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import {
|
||||
buildPackageUploadEntries,
|
||||
filterIgnoredPackageFiles,
|
||||
normalizePackageUploadFiles,
|
||||
} from "../lib/packageUpload";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { formatBytes, formatPublishError, hashFile, uploadFile } from "./upload/-utils";
|
||||
|
||||
export const Route = createFileRoute("/publish-plugin")({
|
||||
validateSearch: (search) => ({
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
name: typeof search.name === "string" ? search.name : undefined,
|
||||
displayName: typeof search.displayName === "string" ? search.displayName : undefined,
|
||||
family:
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
component: PublishPluginRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/publish-plugin" });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (
|
||||
args: { payload: unknown },
|
||||
) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">(
|
||||
search.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
);
|
||||
const [name, setName] = useState(search.name ?? "");
|
||||
const [displayName, setDisplayName] = useState(search.displayName ?? "");
|
||||
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
|
||||
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? "");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const archiveInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
|
||||
directoryInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute("webkitdirectory", "");
|
||||
node.setAttribute("directory", "");
|
||||
}
|
||||
};
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
|
||||
[files],
|
||||
);
|
||||
const normalizedPathSet = useMemo(
|
||||
() => new Set(normalizedPaths.map((path) => path.toLowerCase())),
|
||||
[normalizedPaths],
|
||||
);
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const validationError =
|
||||
oversizedFiles.length > 0
|
||||
? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`
|
||||
: totalBytes > MAX_PUBLISH_TOTAL_BYTES
|
||||
? "Total file size exceeds 50MB."
|
||||
: null;
|
||||
const isMetadataLocked = files.length === 0;
|
||||
const isSubmitting = status !== null;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const normalized = normalizePackageUploadFiles(filtered.files);
|
||||
const nextIgnoredPaths = [...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths])];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
if (prefill.family) setFamily(prefill.family);
|
||||
if (prefill.name) setName(prefill.name);
|
||||
if (prefill.displayName) setDisplayName(prefill.displayName);
|
||||
if (prefill.version) setVersion(prefill.version);
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Publish a native code plugin or bundle plugin release.
|
||||
</p>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
</p>
|
||||
{search.name ? (
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Prefilled for {search.displayName ?? search.name}
|
||||
{search.nextVersion && semver.valid(search.nextVersion) ? ` · suggested ${search.nextVersion}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="card upload-panel">
|
||||
<div
|
||||
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement | null)?.closest("button")) return;
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
void (async () => {
|
||||
const dropped = event.dataTransfer.items?.length
|
||||
? await expandDroppedItems(event.dataTransfer.items)
|
||||
: Array.from(event.dataTransfer.files);
|
||||
await onPickFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={archiveInputRef}
|
||||
className="upload-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={setDirectoryInputRef}
|
||||
className="upload-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<div className="plugin-dropzone-art" aria-hidden="true">
|
||||
<Package size={28} />
|
||||
</div>
|
||||
<div className="upload-dropzone-copy">
|
||||
<div className="upload-dropzone-title-row">
|
||||
<strong>Upload plugin code first</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {formatBytes(totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="upload-dropzone-hint">
|
||||
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
|
||||
of the form.
|
||||
</span>
|
||||
<div className="plugin-dropzone-actions">
|
||||
<button
|
||||
className="btn upload-picker-btn"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Browse files
|
||||
</button>
|
||||
<button
|
||||
className="btn upload-picker-btn plugin-dropzone-secondary"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
directoryInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Choose folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`plugin-upload-summary${isMetadataLocked ? "" : " is-ready"}`}>
|
||||
{normalizedPaths.length === 0 ? (
|
||||
<div className="stat">No plugin package selected yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="plugin-upload-summary-row">
|
||||
<strong>Package detected</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {formatBytes(totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="plugin-upload-summary-copy">
|
||||
{detectedPrefillFields.length > 0
|
||||
? `Autofilled ${detectedPrefillFields.join(", ")}.`
|
||||
: "Package files were detected. Review and fill the release details below."}
|
||||
</div>
|
||||
<div className="plugin-upload-summary-tags">
|
||||
{normalizedPathSet.has("package.json") ? <span className="tag">Package manifest</span> : null}
|
||||
{normalizedPathSet.has("openclaw.plugin.json") ? (
|
||||
<span className="tag">Plugin manifest</span>
|
||||
) : null}
|
||||
{normalizedPathSet.has("openclaw.bundle.json") ? (
|
||||
<span className="tag">Bundle manifest</span>
|
||||
) : null}
|
||||
{normalizedPathSet.has("readme.md") || normalizedPathSet.has("readme.mdx") ? (
|
||||
<span className="tag">README</span>
|
||||
) : null}
|
||||
{ignoredPaths.length > 0 ? (
|
||||
<span className="tag">Ignored {ignoredPaths.length} files</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{validationError ? <div className="tag tag-accent">{validationError}</div> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`card plugin-publish-form${isMetadataLocked ? " is-locked" : ""}`}
|
||||
style={{ display: "grid", gap: 12 }}
|
||||
aria-disabled={isMetadataLocked}
|
||||
>
|
||||
{!isAuthenticated ? <div>Log in to publish plugins.</div> : null}
|
||||
<div className={`plugin-publish-lock-note${isMetadataLocked ? "" : " is-ready"}`}>
|
||||
{isMetadataLocked
|
||||
? "Upload plugin code to detect the package shape and unlock the release form."
|
||||
: "Metadata detected and prefilled. Review it, then fill any missing release details."}
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
value={family}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setFamily(event.target.value as never)}
|
||||
>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
<option value="bundle-plugin">Bundle plugin</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Plugin name"
|
||||
value={name}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={ownerHandle}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publishers ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Version"
|
||||
value={version}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
isMetadataLocked ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(validationError) ||
|
||||
isSubmitting ||
|
||||
(family === "code-plugin" && (!sourceRepo.trim() || !sourceCommit.trim()))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release…");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus("Published. Pending security checks and verification before public listing.");
|
||||
} catch (publishError) {
|
||||
setError(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</button>
|
||||
{error ? <div className="tag tag-accent">{error}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type PluginPublishPrefill = {
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
sourceRepo?: string;
|
||||
bundleFormat?: string;
|
||||
hostTargets?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getStringList(value: unknown) {
|
||||
if (Array.isArray(value)) return value.map(getString).filter(Boolean) as string[];
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function readJsonUploadFile(
|
||||
files: Array<{ file: File; path: string }>,
|
||||
expectedPath: string,
|
||||
): Promise<JsonRecord | null> {
|
||||
const normalizedExpectedPath = expectedPath.toLowerCase();
|
||||
const expectedFileName = normalizedExpectedPath.split("/").at(-1);
|
||||
const entry =
|
||||
files.find((file) => file.path.toLowerCase() === normalizedExpectedPath) ??
|
||||
files.find((file) => file.path.toLowerCase().endsWith(`/${normalizedExpectedPath}`)) ??
|
||||
files.find((file) => {
|
||||
const normalizedPath = file.path.toLowerCase();
|
||||
return expectedFileName ? normalizedPath.split("/").at(-1) === expectedFileName : false;
|
||||
});
|
||||
if (!entry) return null;
|
||||
try {
|
||||
const parsed = JSON.parse((await entry.file.text()).replace(/^\uFEFF/, "")) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGitHubRepo(value: string) {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^git@github\.com:/i, "https://github.com/");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const shorthand = trimmed.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return undefined;
|
||||
const [owner, repo] = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
||||
if (!owner || !repo) return undefined;
|
||||
return `${owner}/${repo}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSourceRepo(packageJson: JsonRecord | null) {
|
||||
if (!packageJson) return undefined;
|
||||
const repository = packageJson.repository;
|
||||
if (typeof repository === "string") return normalizeGitHubRepo(repository);
|
||||
if (isRecord(repository) && typeof repository.url === "string") {
|
||||
return normalizeGitHubRepo(repository.url);
|
||||
}
|
||||
if (typeof packageJson.homepage === "string") return normalizeGitHubRepo(packageJson.homepage);
|
||||
if (isRecord(packageJson.bugs) && typeof packageJson.bugs.url === "string") {
|
||||
return normalizeGitHubRepo(packageJson.bugs.url);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function derivePluginPrefill(
|
||||
files: Array<{ file: File; path: string }>,
|
||||
): Promise<PluginPublishPrefill> {
|
||||
const packageJson = await readJsonUploadFile(files, "package.json");
|
||||
const pluginManifest = await readJsonUploadFile(files, "openclaw.plugin.json");
|
||||
const bundleManifest = await readJsonUploadFile(files, "openclaw.bundle.json");
|
||||
const openclaw = isRecord(packageJson?.openclaw) ? packageJson.openclaw : undefined;
|
||||
const hostTargets = bundleManifest
|
||||
? [...new Set([...getStringList(bundleManifest.hostTargets), ...getStringList(openclaw?.hostTargets)])]
|
||||
: [];
|
||||
|
||||
return {
|
||||
family: pluginManifest ? "code-plugin" : bundleManifest ? "bundle-plugin" : undefined,
|
||||
name: getString(packageJson?.name) ?? getString(pluginManifest?.id) ?? getString(bundleManifest?.id),
|
||||
displayName:
|
||||
getString(packageJson?.displayName) ??
|
||||
getString(pluginManifest?.name) ??
|
||||
getString(bundleManifest?.name),
|
||||
version: getString(packageJson?.version),
|
||||
sourceRepo: extractSourceRepo(packageJson),
|
||||
bundleFormat: getString(bundleManifest?.format) ?? getString(openclaw?.bundleFormat),
|
||||
hostTargets: hostTargets.length > 0 ? hostTargets.join(", ") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function listPrefilledFields(prefill: PluginPublishPrefill) {
|
||||
const fields: string[] = [];
|
||||
if (prefill.family) fields.push("package type");
|
||||
if (prefill.name) fields.push("plugin name");
|
||||
if (prefill.displayName) fields.push("display name");
|
||||
if (prefill.version) fields.push("version");
|
||||
if (prefill.sourceRepo) fields.push("source repo");
|
||||
if (prefill.bundleFormat) fields.push("bundle format");
|
||||
if (prefill.hostTargets) fields.push("host targets");
|
||||
return fields;
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
import { getPublicSlugCollision } from "../lib/slugCollision";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
formatBytes,
|
||||
formatPublishError,
|
||||
hashFile,
|
||||
isTextFile,
|
||||
readText,
|
||||
uploadFile,
|
||||
} from "./upload/-utils";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export const Route = createFileRoute("/publish-skill")({
|
||||
validateSearch: (search) => ({
|
||||
updateSlug: typeof search.updateSlug === "string" ? search.updateSlug : undefined,
|
||||
}),
|
||||
component: Upload,
|
||||
});
|
||||
|
||||
export function Upload() {
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const { updateSlug } = useSearch({ from: "/publish-skill" });
|
||||
const siteMode = getSiteMode();
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const requiredFileLabel = isSoulMode ? "SOUL.md" : "SKILL.md";
|
||||
const contentLabel = isSoulMode ? "soul" : "skill";
|
||||
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishVersion = useAction(
|
||||
isSoulMode ? api.souls.publishVersion : api.skills.publishVersion,
|
||||
);
|
||||
const generateChangelogPreview = useAction(
|
||||
isSoulMode ? api.souls.generateChangelogPreview : api.skills.generateChangelogPreview,
|
||||
);
|
||||
const existingSkill = useQuery(
|
||||
api.skills.getBySlug,
|
||||
!isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existingSoul = useQuery(
|
||||
api.souls.getBySlug,
|
||||
isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existing = (isSoulMode ? existingSoul : existingSkill) as
|
||||
| {
|
||||
skill?: { slug: string; displayName: string };
|
||||
soul?: { slug: string; displayName: string };
|
||||
latestVersion?: { version: string };
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const [hasAttempted, setHasAttempted] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredMacJunkPaths, setIgnoredMacJunkPaths] = useState<string[]>([]);
|
||||
const [slug, setSlug] = useState(updateSlug ?? "");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [version, setVersion] = useState("1.0.0");
|
||||
const [tags, setTags] = useState("latest");
|
||||
const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false);
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [changelogSource, setChangelogSource] = useState<"auto" | "user" | null>(null);
|
||||
const changelogTouchedRef = useRef(false);
|
||||
const changelogRequestRef = useRef(0);
|
||||
const changelogKeyRef = useRef<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const isSubmitting = status !== null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const publisherMemberships = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const [ownerHandle, setOwnerHandle] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFileInputRef = (node: HTMLInputElement | null) => {
|
||||
fileInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute("webkitdirectory", "");
|
||||
node.setAttribute("directory", "");
|
||||
}
|
||||
};
|
||||
const validationRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const stripRoot = useMemo(() => {
|
||||
if (files.length === 0) return null;
|
||||
const paths = files.map((file) => (file.webkitRelativePath || file.name).replace(/^\.\//, ""));
|
||||
if (!paths.every((path) => path.includes("/"))) return null;
|
||||
const firstSegment = paths[0]?.split("/")[0];
|
||||
if (!firstSegment) return null;
|
||||
if (!paths.every((path) => path.startsWith(`${firstSegment}/`))) return null;
|
||||
return firstSegment;
|
||||
}, [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() =>
|
||||
files.map((file) => {
|
||||
const raw = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
if (stripRoot && raw.startsWith(`${stripRoot}/`)) {
|
||||
return raw.slice(stripRoot.length + 1);
|
||||
}
|
||||
return raw;
|
||||
}),
|
||||
[files, stripRoot],
|
||||
);
|
||||
const hasRequiredFile = useMemo(
|
||||
() =>
|
||||
normalizedPaths.some((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
}),
|
||||
[isSoulMode, normalizedPaths],
|
||||
);
|
||||
const sizeLabel = totalBytes ? formatBytes(totalBytes) : "0 B";
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const ignoredMacJunkNote = useMemo(() => {
|
||||
if (ignoredMacJunkPaths.length === 0) return null;
|
||||
const labels = Array.from(
|
||||
new Set(ignoredMacJunkPaths.map((path) => path.split("/").at(-1) ?? path)),
|
||||
).slice(0, 3);
|
||||
const suffix = ignoredMacJunkPaths.length > 3 ? ", ..." : "";
|
||||
const count = ignoredMacJunkPaths.length;
|
||||
return `Ignored ${count} macOS junk file${count === 1 ? "" : "s"} (${labels.join(", ")}${suffix})`;
|
||||
}, [ignoredMacJunkPaths]);
|
||||
const trimmedSlug = slug.trim();
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedChangelog = changelog.trim();
|
||||
const slugAvailability = useQuery(
|
||||
api.skills.checkSlugAvailability,
|
||||
!isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug)
|
||||
? { slug: trimmedSlug.toLowerCase() }
|
||||
: "skip",
|
||||
) as
|
||||
| {
|
||||
available: boolean;
|
||||
reason: "available" | "taken" | "reserved";
|
||||
message: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const slugCollision = useMemo(
|
||||
() =>
|
||||
getPublicSlugCollision({
|
||||
isSoulMode,
|
||||
slug: trimmedSlug,
|
||||
result: slugAvailability,
|
||||
}),
|
||||
[isSoulMode, slugAvailability, trimmedSlug],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return;
|
||||
const name = existing.skill?.displayName ?? existing.soul?.displayName;
|
||||
const nextSlug = existing.skill?.slug ?? existing.soul?.slug;
|
||||
if (nextSlug) setSlug(nextSlug);
|
||||
if (name) setDisplayName(name);
|
||||
const nextVersion = semver.inc(existing.latestVersion.version, "patch");
|
||||
if (nextVersion) setVersion(nextVersion);
|
||||
}, [existing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personalPublisher = publisherMemberships?.find((entry) => entry.publisher.kind === "user");
|
||||
if (personalPublisher?.publisher.handle) {
|
||||
setOwnerHandle(personalPublisher.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publisherMemberships]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changelogTouchedRef.current) return;
|
||||
if (trimmedChangelog) return;
|
||||
if (!trimmedSlug || !SLUG_PATTERN.test(trimmedSlug)) return;
|
||||
if (!semver.valid(version)) return;
|
||||
if (!hasRequiredFile) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const requiredIndex = normalizedPaths.findIndex((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
});
|
||||
if (requiredIndex < 0) return;
|
||||
|
||||
const requiredFile = files[requiredIndex];
|
||||
if (!requiredFile) return;
|
||||
|
||||
const key = `${trimmedSlug}:${version}:${requiredFile.size}:${requiredFile.lastModified}:${normalizedPaths.length}`;
|
||||
if (changelogKeyRef.current === key) return;
|
||||
changelogKeyRef.current = key;
|
||||
|
||||
const requestId = ++changelogRequestRef.current;
|
||||
setChangelogStatus("loading");
|
||||
|
||||
void readText(requiredFile)
|
||||
.then((text) => {
|
||||
if (changelogRequestRef.current !== requestId) return null;
|
||||
return generateChangelogPreview({
|
||||
slug: trimmedSlug,
|
||||
version,
|
||||
readmeText: text.slice(0, 20_000),
|
||||
filePaths: normalizedPaths,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelog(result.changelog);
|
||||
setChangelogSource("auto");
|
||||
setChangelogStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelogStatus("error");
|
||||
});
|
||||
}, [
|
||||
files,
|
||||
generateChangelogPreview,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
normalizedPaths,
|
||||
trimmedChangelog,
|
||||
trimmedSlug,
|
||||
version,
|
||||
]);
|
||||
const parsedTags = useMemo(
|
||||
() =>
|
||||
tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
[tags],
|
||||
);
|
||||
const validation = useMemo(() => {
|
||||
const issues: string[] = [];
|
||||
if (!trimmedSlug) {
|
||||
issues.push("Slug is required.");
|
||||
} else if (!SLUG_PATTERN.test(trimmedSlug)) {
|
||||
issues.push("Slug must be lowercase and use dashes only.");
|
||||
}
|
||||
if (!trimmedName) {
|
||||
issues.push("Display name is required.");
|
||||
}
|
||||
if (!semver.valid(version)) {
|
||||
issues.push("Version must be valid semver (e.g. 1.0.0).");
|
||||
}
|
||||
if (parsedTags.length === 0) {
|
||||
issues.push("At least one tag is required.");
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
issues.push("Accept the MIT-0 license terms to publish this skill.");
|
||||
}
|
||||
if (files.length === 0) {
|
||||
issues.push("Add at least one file.");
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
issues.push(`${requiredFileLabel} is required.`);
|
||||
}
|
||||
const invalidFiles = files.filter((file) => !isTextFile(file));
|
||||
if (invalidFiles.length > 0) {
|
||||
issues.push(
|
||||
`Remove non-text files: ${invalidFiles
|
||||
.slice(0, 3)
|
||||
.map((file) => file.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (oversizedFiles.length > 0) {
|
||||
issues.push(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
issues.push("Total file size exceeds 50MB.");
|
||||
}
|
||||
if (slugCollision) {
|
||||
issues.push(slugCollision.message);
|
||||
}
|
||||
return {
|
||||
issues,
|
||||
ready: issues.length === 0,
|
||||
};
|
||||
}, [
|
||||
trimmedSlug,
|
||||
trimmedName,
|
||||
version,
|
||||
parsedTags.length,
|
||||
acceptedLicenseTerms,
|
||||
files,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
totalBytes,
|
||||
oversizedFiles.length,
|
||||
oversizedFileNames,
|
||||
requiredFileLabel,
|
||||
slugCollision,
|
||||
]);
|
||||
|
||||
// webkitdirectory/directory attributes are set via the ref callback (setFileInputRef)
|
||||
// to ensure they persist across hydration and re-renders (#58)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to publish a {contentLabel}.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function applyExpandedFiles(selected: File[]) {
|
||||
const report = await expandFilesWithReport(selected);
|
||||
setFiles(report.files);
|
||||
setIgnoredMacJunkPaths(report.ignoredMacJunkPaths);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setHasAttempted(true);
|
||||
if (!validation.ready) {
|
||||
if (validationRef.current && "scrollIntoView" in validationRef.current) {
|
||||
validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (slugCollision) {
|
||||
setError(slugCollision.message);
|
||||
return;
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
setError("Accept the MIT-0 license terms to publish this skill.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (oversizedFiles.length > 0) {
|
||||
setError(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
setError("Total size exceeds 50MB per version.");
|
||||
return;
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
setError(`${requiredFileLabel} is required.`);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
|
||||
const uploaded = [] as Array<{
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: string;
|
||||
sha256: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
|
||||
for (const file of files) {
|
||||
const uploadUrl = await generateUploadUrl();
|
||||
const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
const path =
|
||||
stripRoot && rawPath.startsWith(`${stripRoot}/`)
|
||||
? rawPath.slice(stripRoot.length + 1)
|
||||
: rawPath;
|
||||
const sha256 = await hashFile(file);
|
||||
const storageId = await uploadFile(uploadUrl, file);
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("Publishing…");
|
||||
try {
|
||||
const result = await publishVersion({
|
||||
ownerHandle: isSoulMode ? undefined : ownerHandle || undefined,
|
||||
slug: trimmedSlug,
|
||||
displayName: trimmedName,
|
||||
version,
|
||||
changelog: trimmedChangelog,
|
||||
acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms,
|
||||
tags: parsedTags,
|
||||
files: uploaded,
|
||||
});
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setHasAttempted(false);
|
||||
setChangelogSource("user");
|
||||
if (result) {
|
||||
const ownerParam =
|
||||
ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
|
||||
void navigate({
|
||||
to: isSoulMode ? "/souls/$slug" : "/$owner/$slug",
|
||||
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
|
||||
});
|
||||
}
|
||||
} catch (publishError) {
|
||||
setStatus(null);
|
||||
setError(formatPublishError(publishError));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section upload-page">
|
||||
<header className="upload-page-header">
|
||||
<div>
|
||||
<h1 className="upload-page-title">Publish a {contentLabel}</h1>
|
||||
<p className="upload-page-subtitle">
|
||||
Drop a folder with {requiredFileLabel} and text files. We will handle the rest.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="upload-grid">
|
||||
<div className="card upload-panel">
|
||||
<label className="form-label" htmlFor="slug">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder={`${contentLabel}-name`}
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="displayName">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={`My ${contentLabel}`}
|
||||
/>
|
||||
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<label className="form-label" htmlFor="ownerHandle">
|
||||
Owner
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
id="ownerHandle"
|
||||
value={ownerHandle}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publisherMemberships ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<label className="form-label" htmlFor="version">
|
||||
Version
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, stable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
<label
|
||||
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const items = event.dataTransfer.items;
|
||||
void (async () => {
|
||||
const dropped = items?.length
|
||||
? await expandDroppedItems(items)
|
||||
: Array.from(event.dataTransfer.files);
|
||||
await applyExpandedFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={setFileInputRef}
|
||||
className="upload-file-input"
|
||||
id="upload-files"
|
||||
data-testid="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
void applyExpandedFiles(picked);
|
||||
}}
|
||||
/>
|
||||
<div className="upload-dropzone-copy">
|
||||
<div className="upload-dropzone-title-row">
|
||||
<strong>Drop a folder</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="upload-dropzone-hint">
|
||||
We keep folder paths and flatten the outer wrapper automatically.
|
||||
</span>
|
||||
<button
|
||||
className="btn upload-picker-btn"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Choose folder
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="upload-file-list">
|
||||
{files.length === 0 ? (
|
||||
<div className="stat">No files selected.</div>
|
||||
) : (
|
||||
normalizedPaths.map((path) => (
|
||||
<div key={path} className="upload-file-row">
|
||||
<span>{path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{ignoredMacJunkNote ? <div className="stat">{ignoredMacJunkNote}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel" ref={validationRef}>
|
||||
<h2 className="upload-panel-title">Validation</h2>
|
||||
{validation.issues.length === 0 ? (
|
||||
<div className="stat">All checks passed.</div>
|
||||
) : (
|
||||
<ul className="validation-list">
|
||||
{validation.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{slugCollision?.url ? (
|
||||
<div className="stat">
|
||||
Existing skill:{" "}
|
||||
<a href={slugCollision.url} className="upload-link">
|
||||
{slugCollision.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<h2 className="upload-panel-title">License</h2>
|
||||
<div className="upload-license-card">
|
||||
<div className="upload-license-pill">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME}
|
||||
</div>
|
||||
<p className="upload-license-copy">
|
||||
All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{" "}
|
||||
{PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</p>
|
||||
<label className="upload-license-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedLicenseTerms}
|
||||
onChange={(event) => setAcceptedLicenseTerms(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
I have the rights to this skill and agree to publish it under{" "}
|
||||
{PLATFORM_SKILL_LICENSE}.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<label className="form-label" htmlFor="changelog">
|
||||
Changelog
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
id="changelog"
|
||||
rows={6}
|
||||
value={changelog}
|
||||
onChange={(event) => {
|
||||
changelogTouchedRef.current = true;
|
||||
setChangelogSource("user");
|
||||
setChangelog(event.target.value);
|
||||
}}
|
||||
placeholder={`Describe what changed in this ${contentLabel}...`}
|
||||
/>
|
||||
{changelogStatus === "loading" ? <div className="stat">Generating changelog…</div> : null}
|
||||
{changelogStatus === "error" ? (
|
||||
<div className="stat">Could not auto-generate changelog.</div>
|
||||
) : null}
|
||||
{changelogSource === "auto" && changelog ? (
|
||||
<div className="stat">Auto-generated changelog (edit as needed).</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="upload-submit-row">
|
||||
<div className="upload-submit-notes">
|
||||
{error ? (
|
||||
<div className="error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
{hasAttempted && !validation.ready ? (
|
||||
<div className="stat">Fix validation issues to continue.</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary upload-submit-btn"
|
||||
type="submit"
|
||||
disabled={!validation.ready || isSubmitting}
|
||||
>
|
||||
Publish {contentLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+7
-666
@@ -1,672 +1,13 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
import { getPublicSlugCollision } from "../lib/slugCollision";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
formatBytes,
|
||||
formatPublishError,
|
||||
hashFile,
|
||||
isTextFile,
|
||||
readText,
|
||||
uploadFile,
|
||||
} from "./upload/-utils";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/upload")({
|
||||
validateSearch: (search) => ({
|
||||
updateSlug: typeof search.updateSlug === "string" ? search.updateSlug : undefined,
|
||||
}),
|
||||
component: Upload,
|
||||
});
|
||||
|
||||
export function Upload() {
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const { updateSlug } = useSearch({ from: "/upload" });
|
||||
const siteMode = getSiteMode();
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const requiredFileLabel = isSoulMode ? "SOUL.md" : "SKILL.md";
|
||||
const contentLabel = isSoulMode ? "soul" : "skill";
|
||||
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishVersion = useAction(
|
||||
isSoulMode ? api.souls.publishVersion : api.skills.publishVersion,
|
||||
);
|
||||
const generateChangelogPreview = useAction(
|
||||
isSoulMode ? api.souls.generateChangelogPreview : api.skills.generateChangelogPreview,
|
||||
);
|
||||
const existingSkill = useQuery(
|
||||
api.skills.getBySlug,
|
||||
!isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existingSoul = useQuery(
|
||||
api.souls.getBySlug,
|
||||
isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existing = (isSoulMode ? existingSoul : existingSkill) as
|
||||
| {
|
||||
skill?: { slug: string; displayName: string };
|
||||
soul?: { slug: string; displayName: string };
|
||||
latestVersion?: { version: string };
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const [hasAttempted, setHasAttempted] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredMacJunkPaths, setIgnoredMacJunkPaths] = useState<string[]>([]);
|
||||
const [slug, setSlug] = useState(updateSlug ?? "");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [version, setVersion] = useState("1.0.0");
|
||||
const [tags, setTags] = useState("latest");
|
||||
const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false);
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [changelogSource, setChangelogSource] = useState<"auto" | "user" | null>(null);
|
||||
const changelogTouchedRef = useRef(false);
|
||||
const changelogRequestRef = useRef(0);
|
||||
const changelogKeyRef = useRef<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const isSubmitting = status !== null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const publisherMemberships = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const [ownerHandle, setOwnerHandle] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFileInputRef = (node: HTMLInputElement | null) => {
|
||||
fileInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute("webkitdirectory", "");
|
||||
node.setAttribute("directory", "");
|
||||
}
|
||||
};
|
||||
const validationRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const stripRoot = useMemo(() => {
|
||||
if (files.length === 0) return null;
|
||||
const paths = files.map((file) => (file.webkitRelativePath || file.name).replace(/^\.\//, ""));
|
||||
if (!paths.every((path) => path.includes("/"))) return null;
|
||||
const firstSegment = paths[0]?.split("/")[0];
|
||||
if (!firstSegment) return null;
|
||||
if (!paths.every((path) => path.startsWith(`${firstSegment}/`))) return null;
|
||||
return firstSegment;
|
||||
}, [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() =>
|
||||
files.map((file) => {
|
||||
const raw = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
if (stripRoot && raw.startsWith(`${stripRoot}/`)) {
|
||||
return raw.slice(stripRoot.length + 1);
|
||||
}
|
||||
return raw;
|
||||
}),
|
||||
[files, stripRoot],
|
||||
);
|
||||
const hasRequiredFile = useMemo(
|
||||
() =>
|
||||
normalizedPaths.some((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
}),
|
||||
[isSoulMode, normalizedPaths],
|
||||
);
|
||||
const sizeLabel = totalBytes ? formatBytes(totalBytes) : "0 B";
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const ignoredMacJunkNote = useMemo(() => {
|
||||
if (ignoredMacJunkPaths.length === 0) return null;
|
||||
const labels = Array.from(
|
||||
new Set(ignoredMacJunkPaths.map((path) => path.split("/").at(-1) ?? path)),
|
||||
).slice(0, 3);
|
||||
const suffix = ignoredMacJunkPaths.length > 3 ? ", ..." : "";
|
||||
const count = ignoredMacJunkPaths.length;
|
||||
return `Ignored ${count} macOS junk file${count === 1 ? "" : "s"} (${labels.join(", ")}${suffix})`;
|
||||
}, [ignoredMacJunkPaths]);
|
||||
const trimmedSlug = slug.trim();
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedChangelog = changelog.trim();
|
||||
const slugAvailability = useQuery(
|
||||
api.skills.checkSlugAvailability,
|
||||
!isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug)
|
||||
? { slug: trimmedSlug.toLowerCase() }
|
||||
: "skip",
|
||||
) as
|
||||
| {
|
||||
available: boolean;
|
||||
reason: "available" | "taken" | "reserved";
|
||||
message: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const slugCollision = useMemo(
|
||||
() =>
|
||||
getPublicSlugCollision({
|
||||
isSoulMode,
|
||||
slug: trimmedSlug,
|
||||
result: slugAvailability,
|
||||
}),
|
||||
[isSoulMode, slugAvailability, trimmedSlug],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return;
|
||||
const name = existing.skill?.displayName ?? existing.soul?.displayName;
|
||||
const nextSlug = existing.skill?.slug ?? existing.soul?.slug;
|
||||
if (nextSlug) setSlug(nextSlug);
|
||||
if (name) setDisplayName(name);
|
||||
const nextVersion = semver.inc(existing.latestVersion.version, "patch");
|
||||
if (nextVersion) setVersion(nextVersion);
|
||||
}, [existing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personalPublisher = publisherMemberships?.find((entry) => entry.publisher.kind === "user");
|
||||
if (personalPublisher?.publisher.handle) {
|
||||
setOwnerHandle(personalPublisher.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publisherMemberships]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changelogTouchedRef.current) return;
|
||||
if (trimmedChangelog) return;
|
||||
if (!trimmedSlug || !SLUG_PATTERN.test(trimmedSlug)) return;
|
||||
if (!semver.valid(version)) return;
|
||||
if (!hasRequiredFile) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const requiredIndex = normalizedPaths.findIndex((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/publish-skill",
|
||||
search,
|
||||
});
|
||||
if (requiredIndex < 0) return;
|
||||
|
||||
const requiredFile = files[requiredIndex];
|
||||
if (!requiredFile) return;
|
||||
|
||||
const key = `${trimmedSlug}:${version}:${requiredFile.size}:${requiredFile.lastModified}:${normalizedPaths.length}`;
|
||||
if (changelogKeyRef.current === key) return;
|
||||
changelogKeyRef.current = key;
|
||||
|
||||
const requestId = ++changelogRequestRef.current;
|
||||
setChangelogStatus("loading");
|
||||
|
||||
void readText(requiredFile)
|
||||
.then((text) => {
|
||||
if (changelogRequestRef.current !== requestId) return null;
|
||||
return generateChangelogPreview({
|
||||
slug: trimmedSlug,
|
||||
version,
|
||||
readmeText: text.slice(0, 20_000),
|
||||
filePaths: normalizedPaths,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelog(result.changelog);
|
||||
setChangelogSource("auto");
|
||||
setChangelogStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelogStatus("error");
|
||||
});
|
||||
}, [
|
||||
files,
|
||||
generateChangelogPreview,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
normalizedPaths,
|
||||
trimmedChangelog,
|
||||
trimmedSlug,
|
||||
version,
|
||||
]);
|
||||
const parsedTags = useMemo(
|
||||
() =>
|
||||
tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
[tags],
|
||||
);
|
||||
const validation = useMemo(() => {
|
||||
const issues: string[] = [];
|
||||
if (!trimmedSlug) {
|
||||
issues.push("Slug is required.");
|
||||
} else if (!SLUG_PATTERN.test(trimmedSlug)) {
|
||||
issues.push("Slug must be lowercase and use dashes only.");
|
||||
}
|
||||
if (!trimmedName) {
|
||||
issues.push("Display name is required.");
|
||||
}
|
||||
if (!semver.valid(version)) {
|
||||
issues.push("Version must be valid semver (e.g. 1.0.0).");
|
||||
}
|
||||
if (parsedTags.length === 0) {
|
||||
issues.push("At least one tag is required.");
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
issues.push("Accept the MIT-0 license terms to publish this skill.");
|
||||
}
|
||||
if (files.length === 0) {
|
||||
issues.push("Add at least one file.");
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
issues.push(`${requiredFileLabel} is required.`);
|
||||
}
|
||||
const invalidFiles = files.filter((file) => !isTextFile(file));
|
||||
if (invalidFiles.length > 0) {
|
||||
issues.push(
|
||||
`Remove non-text files: ${invalidFiles
|
||||
.slice(0, 3)
|
||||
.map((file) => file.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (oversizedFiles.length > 0) {
|
||||
issues.push(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
issues.push("Total file size exceeds 50MB.");
|
||||
}
|
||||
if (slugCollision) {
|
||||
issues.push(slugCollision.message);
|
||||
}
|
||||
return {
|
||||
issues,
|
||||
ready: issues.length === 0,
|
||||
};
|
||||
}, [
|
||||
trimmedSlug,
|
||||
trimmedName,
|
||||
version,
|
||||
parsedTags.length,
|
||||
acceptedLicenseTerms,
|
||||
files,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
totalBytes,
|
||||
oversizedFiles.length,
|
||||
oversizedFileNames,
|
||||
requiredFileLabel,
|
||||
slugCollision,
|
||||
]);
|
||||
|
||||
// webkitdirectory/directory attributes are set via the ref callback (setFileInputRef)
|
||||
// to ensure they persist across hydration and re-renders (#58)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to upload a {contentLabel}.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function applyExpandedFiles(selected: File[]) {
|
||||
const report = await expandFilesWithReport(selected);
|
||||
setFiles(report.files);
|
||||
setIgnoredMacJunkPaths(report.ignoredMacJunkPaths);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setHasAttempted(true);
|
||||
if (!validation.ready) {
|
||||
if (validationRef.current && "scrollIntoView" in validationRef.current) {
|
||||
validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (slugCollision) {
|
||||
setError(slugCollision.message);
|
||||
return;
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
setError("Accept the MIT-0 license terms to publish this skill.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (oversizedFiles.length > 0) {
|
||||
setError(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
setError("Total size exceeds 50MB per version.");
|
||||
return;
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
setError(`${requiredFileLabel} is required.`);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
|
||||
const uploaded = [] as Array<{
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: string;
|
||||
sha256: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
|
||||
for (const file of files) {
|
||||
const uploadUrl = await generateUploadUrl();
|
||||
const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
const path =
|
||||
stripRoot && rawPath.startsWith(`${stripRoot}/`)
|
||||
? rawPath.slice(stripRoot.length + 1)
|
||||
: rawPath;
|
||||
const sha256 = await hashFile(file);
|
||||
const storageId = await uploadFile(uploadUrl, file);
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("Publishing…");
|
||||
try {
|
||||
const result = await publishVersion({
|
||||
ownerHandle: isSoulMode ? undefined : ownerHandle || undefined,
|
||||
slug: trimmedSlug,
|
||||
displayName: trimmedName,
|
||||
version,
|
||||
changelog: trimmedChangelog,
|
||||
acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms,
|
||||
tags: parsedTags,
|
||||
files: uploaded,
|
||||
});
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setHasAttempted(false);
|
||||
setChangelogSource("user");
|
||||
if (result) {
|
||||
const ownerParam =
|
||||
ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
|
||||
void navigate({
|
||||
to: isSoulMode ? "/souls/$slug" : "/$owner/$slug",
|
||||
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
|
||||
});
|
||||
}
|
||||
} catch (publishError) {
|
||||
setStatus(null);
|
||||
setError(formatPublishError(publishError));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section upload-page">
|
||||
<header className="upload-page-header">
|
||||
<div>
|
||||
<h1 className="upload-page-title">Publish a {contentLabel}</h1>
|
||||
<p className="upload-page-subtitle">
|
||||
Drop a folder with {requiredFileLabel} and text files. We will handle the rest.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="upload-grid">
|
||||
<div className="card upload-panel">
|
||||
<label className="form-label" htmlFor="slug">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder={`${contentLabel}-name`}
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="displayName">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={`My ${contentLabel}`}
|
||||
/>
|
||||
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<label className="form-label" htmlFor="ownerHandle">
|
||||
Owner
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
id="ownerHandle"
|
||||
value={ownerHandle}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publisherMemberships ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<label className="form-label" htmlFor="version">
|
||||
Version
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, stable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
<label
|
||||
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const items = event.dataTransfer.items;
|
||||
void (async () => {
|
||||
const dropped = items?.length
|
||||
? await expandDroppedItems(items)
|
||||
: Array.from(event.dataTransfer.files);
|
||||
await applyExpandedFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={setFileInputRef}
|
||||
className="upload-file-input"
|
||||
id="upload-files"
|
||||
data-testid="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
void applyExpandedFiles(picked);
|
||||
}}
|
||||
/>
|
||||
<div className="upload-dropzone-copy">
|
||||
<div className="upload-dropzone-title-row">
|
||||
<strong>Drop a folder</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="upload-dropzone-hint">
|
||||
We keep folder paths and flatten the outer wrapper automatically.
|
||||
</span>
|
||||
<button
|
||||
className="btn upload-picker-btn"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Choose folder
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="upload-file-list">
|
||||
{files.length === 0 ? (
|
||||
<div className="stat">No files selected.</div>
|
||||
) : (
|
||||
normalizedPaths.map((path) => (
|
||||
<div key={path} className="upload-file-row">
|
||||
<span>{path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{ignoredMacJunkNote ? <div className="stat">{ignoredMacJunkNote}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel" ref={validationRef}>
|
||||
<h2 className="upload-panel-title">Validation</h2>
|
||||
{validation.issues.length === 0 ? (
|
||||
<div className="stat">All checks passed.</div>
|
||||
) : (
|
||||
<ul className="validation-list">
|
||||
{validation.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{slugCollision?.url ? (
|
||||
<div className="stat">
|
||||
Existing skill:{" "}
|
||||
<a href={slugCollision.url} className="upload-link">
|
||||
{slugCollision.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<h2 className="upload-panel-title">License</h2>
|
||||
<div className="upload-license-card">
|
||||
<div className="upload-license-pill">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME}
|
||||
</div>
|
||||
<p className="upload-license-copy">
|
||||
All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{" "}
|
||||
{PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</p>
|
||||
<label className="upload-license-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedLicenseTerms}
|
||||
onChange={(event) => setAcceptedLicenseTerms(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
I have the rights to this skill and agree to publish it under{" "}
|
||||
{PLATFORM_SKILL_LICENSE}.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<label className="form-label" htmlFor="changelog">
|
||||
Changelog
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
id="changelog"
|
||||
rows={6}
|
||||
value={changelog}
|
||||
onChange={(event) => {
|
||||
changelogTouchedRef.current = true;
|
||||
setChangelogSource("user");
|
||||
setChangelog(event.target.value);
|
||||
}}
|
||||
placeholder={`Describe what changed in this ${contentLabel}...`}
|
||||
/>
|
||||
{changelogStatus === "loading" ? <div className="stat">Generating changelog…</div> : null}
|
||||
{changelogStatus === "error" ? (
|
||||
<div className="stat">Could not auto-generate changelog.</div>
|
||||
) : null}
|
||||
{changelogSource === "auto" && changelog ? (
|
||||
<div className="stat">Auto-generated changelog (edit as needed).</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="upload-submit-row">
|
||||
<div className="upload-submit-notes">
|
||||
{error ? (
|
||||
<div className="error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
{hasAttempted && !validation.ready ? (
|
||||
<div className="stat">Fix validation issues to continue.</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary upload-submit-btn"
|
||||
type="submit"
|
||||
disabled={!validation.ready || isSubmitting}
|
||||
>
|
||||
Publish {contentLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
|
||||
import { getUserFacingConvexError } from "../../lib/convexError";
|
||||
|
||||
export async function uploadFile(uploadUrl: string, file: File) {
|
||||
|
||||
+299
-56
@@ -620,6 +620,24 @@ code {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plugin-dropzone-art {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #a34129;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(255, 231, 220, 0.88));
|
||||
border: 1px solid rgba(255, 118, 84, 0.22);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-dropzone-art {
|
||||
color: #ffd8c8;
|
||||
background: linear-gradient(145deg, rgba(33, 49, 60, 0.92), rgba(22, 35, 44, 0.9));
|
||||
border-color: rgba(255, 131, 95, 0.24);
|
||||
}
|
||||
|
||||
.upload-dropzone-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -666,6 +684,103 @@ code {
|
||||
color: #ffd9cb;
|
||||
}
|
||||
|
||||
.plugin-dropzone-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plugin-dropzone-secondary {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-dropzone-secondary {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.plugin-publish-form {
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
filter 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.plugin-publish-form.is-locked {
|
||||
opacity: 0.58;
|
||||
filter: saturate(0.75);
|
||||
}
|
||||
|
||||
.plugin-publish-lock-note {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.plugin-publish-lock-note.is-ready {
|
||||
color: #1a6b5b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-publish-form.is-locked .plugin-publish-lock-note {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-publish-lock-note.is-ready {
|
||||
color: #8ef0c2;
|
||||
}
|
||||
|
||||
.plugin-upload-summary {
|
||||
border-top: 1px solid rgba(255, 118, 84, 0.16);
|
||||
padding-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-upload-summary {
|
||||
border-top-color: rgba(255, 131, 95, 0.18);
|
||||
}
|
||||
|
||||
.plugin-upload-summary.is-ready {
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(43, 198, 164, 0.24);
|
||||
background: linear-gradient(135deg, rgba(43, 198, 164, 0.08), rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
|
||||
[data-theme="dark"] .plugin-upload-summary.is-ready {
|
||||
border-color: rgba(88, 218, 173, 0.28);
|
||||
background: linear-gradient(135deg, rgba(24, 74, 64, 0.5), rgba(18, 35, 46, 0.82));
|
||||
}
|
||||
|
||||
.plugin-upload-summary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-upload-summary-row strong {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.plugin-upload-summary-copy {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.plugin-upload-summary.is-ready .plugin-upload-summary-copy {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.plugin-upload-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
border-top: 1px solid rgba(255, 118, 84, 0.16);
|
||||
padding-top: 10px;
|
||||
@@ -3511,54 +3626,172 @@ html.theme-transition::view-transition-new(theme) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
gap: 16px;
|
||||
.dashboard-owner-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-empty h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
.dashboard-owner-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-empty p {
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.dashboard-collection-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-skill-card {
|
||||
.dashboard-collection-block + .dashboard-collection-block {
|
||||
border-top: 1px solid var(--card-border);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.dashboard-collection-title {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.dashboard-inline-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px dashed var(--card-border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-skill-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
.dashboard-inline-empty-copy {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-skill-info {
|
||||
flex: 1;
|
||||
.dashboard-inline-empty-copy strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dashboard-list {
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-header,
|
||||
.dashboard-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.15fr) minmax(0, 1fr) minmax(240px, 0.95fr) auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.dashboard-list-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-row {
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-list-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dashboard-list-row:hover {
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.dashboard-list-primary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-list-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-list-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-inline-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-inline-metrics {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-inline-metrics span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-list-summary {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.dashboard-list-status {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-inline-status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--card-border);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-skill-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
@@ -3570,33 +3803,28 @@ html.theme-transition::view-transition-new(theme) {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dashboard-skill-slug {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dashboard-skill-description {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-muted);
|
||||
margin: 8px 0 0;
|
||||
.dashboard-inline-status-note {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-skill-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.dashboard-skill-actions {
|
||||
.dashboard-tag-warning {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dashboard-tag-danger {
|
||||
background: color-mix(in srgb, #ef4444 18%, transparent);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dashboard-tag-success {
|
||||
background: color-mix(in srgb, #10b981 18%, transparent);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.dashboard-row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
@@ -3620,16 +3848,31 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dashboard-skill-card {
|
||||
.dashboard-header,
|
||||
.dashboard-section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-inline-empty {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-list-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-list-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-skill-actions {
|
||||
.dashboard-row-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-skill-actions .btn {
|
||||
.dashboard-row-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ const config = defineConfig({
|
||||
viteReact(),
|
||||
],
|
||||
build: {
|
||||
// Keep the shipped client bundle parseable in Safari/WebKit.
|
||||
target: "safari15",
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
onwarn: handleRollupWarning,
|
||||
|
||||
Reference in New Issue
Block a user