Compare commits

...
Author SHA1 Message Date
ImLukeF 8a350d953c fix: preserve exact skill slug search matches
Fixes #1322.

Credit to @Yyang100 for the original report and repro.
2026-03-28 16:27:26 +11:00
Peter Steinberger 3fb3150ee2 fix: allow package downloads while VT scan is pending 2026-03-28 02:54:10 +00:00
Peter Steinberger 7dbc0fc3bb fix: unblock verified community package scans 2026-03-27 23:44:45 +00:00
Peter Steinberger fc4f8644eb refactor: centralize package scan state resolution 2026-03-27 02:10:01 +00:00
Peter Steinberger dda6d55fbf fix: unblock package vt scan fallback stalls 2026-03-27 02:06:03 +00:00
Vincent Koc e014759b40 Merge pull request #1191 from openclaw/vincentkoc-code/fix-plugin-compatibility 2026-03-24 22:55:12 -07:00
Peter Steinberger 2186c41c48 fix: prioritize recent package vt backfills 2026-03-24 10:19:28 -07:00
Peter Steinberger c30c182478 fix: harden package vt scan scheduling 2026-03-24 10:11:26 -07:00
Peter Steinberger 3080567964 perf: tighten package search candidate reads 2026-03-23 23:23:38 -07:00
Peter Steinberger f304541561 perf: collapse package readme fallback requests 2026-03-23 23:23:36 -07:00
Peter Steinberger 91224ada13 perf: parallelize plugin detail reads 2026-03-23 23:04:36 -07:00
Peter Steinberger d5fbaeef81 perf: unify plugin catalog reads 2026-03-23 23:03:01 -07:00
Peter Steinberger 3706018b72 docs: clarify abuse policy for leaked data and account farming 2026-03-23 19:42:20 -07:00
Peter Steinberger 62e616f635 fix: raise http api rate limits 2026-03-23 19:06:49 -07:00
Peter Steinberger 9013d324c8 fix: forward client ip headers for ssr package api 2026-03-23 19:03:12 -07:00
Peter Steinberger c1363ec8d0 feat: add acceptable usage about page 2026-03-23 17:37:17 -07:00
Peter Steinberger 7e09196f92 feat: backfill static plugin scans 2026-03-23 15:58:02 -07:00
Peter Steinberger 807043b4b0 feat: surface plugin security scans 2026-03-23 15:42:59 -07:00
Vincent Koc 972fe35935 fix(plugins): improve package upload detection UX 2026-03-23 15:04:30 -07:00
Vincent Koc 16ee540f5d fix(vt): recover plugin package scan retries 2026-03-23 14:57:39 -07:00
Vincent Koc f541882d55 feat(plugins): gate publish form behind upload 2026-03-23 14:49:42 -07:00
Vincent Koc 95bc156747 test(e2e): align header smoke nav labels 2026-03-23 14:46:06 -07:00
Vincent Koc bf7422022f fix(auth): unblock safari cli github login 2026-03-23 14:28:42 -07:00
Vincent Koc 230e5b91f8 fix(publishing): rename publish routes and clarify import scope 2026-03-23 13:54:47 -07:00
Vincent Koc 70dcf21e37 fix(shell): streamline publisher inventory navigation 2026-03-23 13:45:42 -07:00
Vincent Koc 932a1fb30c fix(dashboard): improve publisher plugin card layout 2026-03-23 13:34:28 -07:00
Vincent Koc 44fe60b701 fix(plugins): keep public package routes public 2026-03-23 13:28:26 -07:00
Vincent Koc 1c057ca9b9 fix(dashboard): streamline publisher plugin workflow 2026-03-23 13:24:46 -07:00
Vincent Koc 9f793d1336 feat(dashboard): add publisher plugin status panel 2026-03-23 13:16:30 -07:00
Vincent Koc aa9295bea9 fix(plugins): forward browser auth to package api 2026-03-23 13:05:41 -07:00
Vincent Koc 370eea4977 fix(plugins): recover owner package detail after auth 2026-03-23 12:53:35 -07:00
Vincent Koc 59e6819020 fix(publishers): remove leftover conflict marker 2026-03-23 12:37:53 -07:00
Vincent Koc 52b633f4e9 fix(publishers): skip digest scheduling without scheduler 2026-03-23 12:28:22 -07:00
Vincent Koc 6b1e6ca1c9 Merge pull request #1203 from openclaw/vincentkoc-code/fix-personal-publisher-pagination
fix: split publisher digest sync scheduling
2026-03-23 12:21:51 -07:00
Vincent Koc 6c112eccb7 chore: use typed internal digest refs 2026-03-23 12:20:59 -07:00
Vincent Koc f9e9effcdd fix(publishers): split owner digest sync scheduling 2026-03-23 12:14:35 -07:00
Peter Steinberger b5d2d0fefa fix: heal missing personal publishers during publish 2026-03-23 10:43:17 -07:00
Vincent Koc 48d0fc91f3 fix(packages): keep plugin api separate from gateway version 2026-03-23 09:08:49 -07:00
LukeandPeter Steinberger fefb2340a8 feat: redesign plugins page and skills list view (#1166)
* feat: redesign plugins page and skills list view

Redesigned the plugins page with a cleaner toolbar (pill search,
toggle filter buttons) and simplified card layout. Added a proper
table-style list view for skills with skill name, version, summary,
and author avatar columns. Also polished the sort dropdown with a
chevron indicator, added card shadows for better separation, and
tightened up the theme toggle and sign-in button.

* feat: add publisher org ownership

* feat: migrate legacy publisher handles to orgs

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-23 03:44:17 -07:00
64 changed files with 6062 additions and 1912 deletions
+2
View File
@@ -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;
+7
View File
@@ -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, {});
+34
View File
@@ -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
View File
@@ -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));
+14
View File
@@ -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",
+253 -31
View File
@@ -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({
+6
View File
@@ -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
View File
@@ -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;
+3 -3
View File
@@ -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 = {
+45 -22
View File
@@ -46,6 +46,7 @@ describe("packageRegistry", () => {
expect(result.runtimeId).toBe("demo.plugin");
expect(result.compatibility?.pluginApiRange).toBe("^1.2.0");
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.0");
expect(result.capabilities.executesCode).toBe(true);
expect(result.capabilities.toolNames).toContain("demoTool");
expect(result.verification.tier).toBe("source-linked");
@@ -70,38 +71,60 @@ describe("packageRegistry", () => {
).toThrow("source repo and commit");
});
it("infers compatibility for legacy openclaw extension manifests", () => {
const result = extractCodePluginArtifacts({
packageName: "@openclaw/matrix",
it("maps legacy minHostVersion to minGatewayVersion instead of pluginApiRange", () => {
expect(() =>
extractCodePluginArtifacts({
packageName: "@openclaw/matrix",
packageJson: {
name: "@openclaw/matrix",
version: "2026.3.13",
openclaw: {
extensions: ["./index.ts"],
install: {
npmSpec: "@openclaw/matrix",
localPath: "extensions/matrix",
defaultChoice: "npm",
minHostVersion: "2026.3.13",
},
},
},
pluginManifest: {
id: "matrix",
channels: ["matrix"],
configSchema: { type: "object" },
},
source: {
kind: "github",
url: "https://github.com/openclaw/openclaw",
repo: "openclaw/openclaw",
ref: "refs/tags/v2026.3.13",
commit: "abc123",
path: "extensions/matrix",
importedAt: Date.now(),
},
}),
).toThrow("package.json openclaw.compat.pluginApi is required");
});
it("extracts legacy minHostVersion as minGatewayVersion while preserving build metadata", () => {
const result = extractBundlePluginArtifacts({
packageName: "@openclaw/matrix-bundle",
packageJson: {
name: "@openclaw/matrix",
name: "@openclaw/matrix-bundle",
version: "2026.3.13",
openclaw: {
extensions: ["./index.ts"],
install: {
npmSpec: "@openclaw/matrix",
localPath: "extensions/matrix",
defaultChoice: "npm",
minHostVersion: "2026.3.13",
},
},
},
pluginManifest: {
id: "matrix",
channels: ["matrix"],
configSchema: { type: "object" },
},
source: {
kind: "github",
url: "https://github.com/openclaw/openclaw",
repo: "openclaw/openclaw",
ref: "refs/tags/v2026.3.13",
commit: "abc123",
path: "extensions/matrix",
importedAt: Date.now(),
bundleManifest: {
hostTargets: ["openclaw"],
},
});
expect(result.compatibility?.pluginApiRange).toBe(">=2026.3.13");
expect(result.compatibility?.pluginApiRange).toBeUndefined();
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.13");
expect(result.compatibility?.builtWithOpenClawVersion).toBe("2026.3.13");
});
-11
View File
@@ -178,24 +178,13 @@ function extractOpenClawBlock(packageJson: JsonRecord | undefined) {
function extractCompatibility(packageJson: JsonRecord | undefined): PackageCompatibility | undefined {
const { openclaw, compat, build } = extractOpenClawBlock(packageJson);
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
const peerDependencies = isRecord(packageJson?.peerDependencies)
? packageJson.peerDependencies
: undefined;
const version =
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
const peerOpenClaw =
typeof peerDependencies?.openclaw === "string" ? peerDependencies.openclaw.trim() : undefined;
const minHostVersion =
typeof install?.minHostVersion === "string" ? install.minHostVersion.trim() : undefined;
const compatibility: PackageCompatibility = {};
if (typeof compat?.pluginApi === "string") {
compatibility.pluginApiRange = compat.pluginApi.trim();
} else if (peerOpenClaw) {
compatibility.pluginApiRange = peerOpenClaw;
} else if (minHostVersion) {
compatibility.pluginApiRange = minHostVersion;
} else if (version) {
compatibility.pluginApiRange = `>=${version}`;
}
if (typeof compat?.minGatewayVersion === "string") {
compatibility.minGatewayVersion = compat.minGatewayVersion.trim();
+41
View File
@@ -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,
}),
);
});
});
+57
View File
@@ -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;
}
+18 -11
View File
@@ -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;
@@ -290,15 +307,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);
}
+665 -3
View File
@@ -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
View File
@@ -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;
}
+61
View File
@@ -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);
@@ -166,6 +171,62 @@ describe("publishers membership controls", () => {
});
});
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", () => {
it("converts a legacy personal publisher into an org and rehomes package ownership", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+23 -12
View File
@@ -8,6 +8,7 @@ import {
ensurePersonalPublisherForUser,
getPublisherByHandle,
getPublisherMembership,
getPersonalPublisherForUserOrFallback,
getPersonalPublisherForUser,
isPublisherRoleAllowed,
normalizePublisherHandle,
@@ -350,7 +351,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 +362,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 +404,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 +417,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 +429,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;
},
});
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -4519,7 +4519,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",
+512 -2
View File
@@ -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
View File
@@ -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,
});
}
}
},
});
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+7
View File
@@ -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
+4 -13
View File
@@ -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") {
+55
View File
@@ -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"),
});
+96
View File
@@ -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<{
+44
View File
@@ -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({
File diff suppressed because one or more lines are too long
+1
View File
@@ -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";
+1
View File
@@ -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",
+1 -1
View File
@@ -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"}
+55
View File
@@ -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"),
});
+1
View File
@@ -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",
+2
View File
@@ -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(),
}));
+60 -1
View File
@@ -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);
});
});
+78 -17
View File
@@ -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"), {
+1 -1
View File
@@ -252,7 +252,7 @@ describe("SkillsIndex", () => {
});
const titles = Array.from(
document.querySelectorAll(".skills-row-title > span:first-child"),
document.querySelectorAll(".skills-table-name > span:first-child"),
).map((node) => node.textContent);
expect(titles[0]).toBe("Older High Score");
+1 -1
View File
@@ -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,
+4
View File
@@ -17,6 +17,10 @@ vi.mock("../convex/client", () => ({
convex: {},
}));
vi.mock("./UserBootstrap", () => ({
UserBootstrap: () => null,
}));
describe("AuthCodeHandler", () => {
beforeEach(() => {
signInMock.mockReset();
+6 -14
View File
@@ -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>
+93 -43
View File
@@ -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");
});
});
+75 -208
View File
@@ -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());
}
+15 -6
View File
@@ -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,
+52
View 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();
});
});
+4 -3
View File
@@ -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,
};
}
+63
View File
@@ -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,
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+4 -4
View File
@@ -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"
+11 -1
View File
@@ -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,
},
});
},
});
+14 -5
View File
@@ -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>
+138 -132
View File
@@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { fetchPluginCatalog, type PackageListItem } from "../../lib/packageApi";
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
import { familyLabel } from "../../lib/packageLabels";
type PluginSearchState = {
q?: string;
@@ -97,90 +97,102 @@ export function PluginsIndex() {
Plugins
</h1>
<p className="section-subtitle" style={{ marginBottom: 0 }}>
Code plugins and bundle plugins for OpenClaw.
Browse the plugin catalog.
</p>
</header>
<div className="card" style={{ display: "grid", gap: 12, marginBottom: 18 }}>
<form
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
style={{ display: "grid", gap: 12 }}
>
<form
className="skills-toolbar"
onSubmit={(event) => {
event.preventDefault();
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
}),
});
}}
>
<div className="skills-search">
<input
className="input"
placeholder="Search plugins"
className="skills-search-input"
placeholder="Search plugins"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<select
className="input"
value={search.family ?? ""}
onChange={(event) => {
const value = event.target.value as PluginSearchState["family"] | "";
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: value || undefined,
}),
});
}}
>
<option value="">All plugins</option>
<option value="code-plugin">Code plugins</option>
<option value="bundle-plugin">Bundle plugins</option>
</select>
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={search.verified ?? false}
onChange={(event) => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: event.target.checked || undefined,
}),
});
}}
/>
Verified only
</label>
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={search.executesCode ?? false}
onChange={(event) => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: event.target.checked || undefined,
}),
});
}}
/>
Executes code
</label>
<Link className="btn" to="/plugins/new">
Publish Plugin
</Link>
</div>
</form>
</div>
</div>
<div className="skills-toolbar-row">
<select
className="skills-sort"
value={search.family ?? ""}
onChange={(event) => {
const value = event.target.value as PluginSearchState["family"] | "";
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
family: value || undefined,
}),
});
}}
aria-label="Filter by type"
>
<option value="">All plugins</option>
<option value="code-plugin">Code plugins</option>
<option value="bundle-plugin">Bundle plugins</option>
</select>
<button
className="search-filter-button"
type="button"
aria-pressed={search.verified ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
verified: prev.verified ? undefined : true,
}),
});
}}
>
Verified
</button>
<button
className="search-filter-button"
type="button"
aria-pressed={search.executesCode ?? false}
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
q: query.trim() || undefined,
executesCode: prev.executesCode ? undefined : true,
}),
});
}}
>
Executes code
</button>
<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>
{items.length === 0 ? (
<div className="card">No plugins match that filter.</div>
@@ -192,73 +204,67 @@ export function PluginsIndex() {
key={item.name}
to="/plugins/$name"
params={{ name: item.name }}
className="skill-card"
className="card skill-card"
>
<div className="skill-card-tags">
<span className="tag">{familyLabel(item.family)}</span>
{item.executesCode ? (
<span className="tag tag-accent">
{packageCapabilityLabel(item.family, item.executesCode)}
<span className="tag tag-compact">{familyLabel(item.family)}</span>
{item.isOfficial ? (
<span className="tag tag-compact tag-accent">
<VerifiedBadge /> Verified
</span>
) : null}
</div>
<div className="skill-card-title">{item.displayName}</div>
<div className="skills-row-slug">{item.name}</div>
<div className="skill-card-summary">
<h3 className="skill-card-title">{item.displayName}</h3>
<p className="skill-card-summary">
{item.summary ?? "No summary provided."}
</div>
<div className="skill-card-footer skill-card-footer-inline">
<div className="stat">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community plugin"}
{item.latestVersion ? ` · v${item.latestVersion}` : ""}
</div>
{item.isOfficial ? <VerifiedBadge /> : null}
</p>
<div className="skill-card-footer" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="stat">
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
</span>
{item.latestVersion ? (
<span className="stat">v{item.latestVersion}</span>
) : null}
</div>
</Link>
))}
</div>
{!search.q && (search.cursor || nextCursor) ? (
<div
className="card"
style={{ display: "flex", gap: 12, flexWrap: "wrap", justifyContent: "space-between", marginTop: 18 }}
style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}
>
<div className="section-subtitle" style={{ margin: 0 }}>
Browsing {items.length} plugin{items.length === 1 ? "" : "s"} per page.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{search.cursor ? (
<button
className="btn"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</button>
) : null}
{nextCursor ? (
<button
className="btn btn-primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</button>
) : null}
</div>
{search.cursor ? (
<button
className="btn"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
}),
});
}}
>
First page
</button>
) : null}
{nextCursor ? (
<button
className="btn btn-primary"
type="button"
onClick={() => {
void navigate({
search: (prev) => ({
...prev,
cursor: nextCursor,
}),
});
}}
>
Next page
</button>
) : null}
</div>
) : null}
</>
+18 -290
View File
@@ -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>
);
}
},
});
+607
View File
@@ -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;
}
+672
View File
@@ -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";
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>
);
}
+30 -35
View File
@@ -78,49 +78,44 @@ export function SkillsResults({
})}
</div>
) : (
<div className="skills-list">
<div className="skills-table">
<div className="skills-table-header">
<span>Skill</span>
<span>Summary</span>
<span>Author</span>
<span className="skills-table-stats">Stats</span>
</div>
{sorted.map((entry) => {
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
const isPlugin = Boolean(clawdis?.nix?.plugin);
const platforms = getPlatformLabels(clawdis?.os, clawdis?.nix?.systems);
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
<Link key={skill._id} className="skills-table-row" to={skillHref}>
<span className="skills-table-name">
<span>
{skill.displayName}
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag">
{badge}
</span>
<span key={badge} className="tag tag-compact">{badge}</span>
))}
{isPlugin ? (
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
) : null}
{platforms.map((label) => (
<span key={label} className="tag tag-compact">
{label}
</span>
))}
</div>
<div className="skills-row-summary">
{skill.summary ?? "No summary provided."}
</div>
<div className="skills-row-owner">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix="by"
link={false}
/>
</div>
</div>
<div className="skills-row-metrics">
</span>
{entry.latestVersion?.version ? (
<span className="skills-table-version">v{entry.latestVersion.version}</span>
) : null}
</span>
<span className="skills-table-summary">
{skill.summary ?? "No summary provided."}
</span>
<span className="skills-table-author">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix=""
link={false}
/>
</span>
<span className="skills-table-stats">
<SkillMetricsRow stats={skill.stats} />
</div>
</span>
</Link>
);
})}
+7 -666
View File
@@ -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>
);
}
},
});
+442 -58
View File
@@ -248,6 +248,30 @@ code {
.theme-toggle {
display: inline-flex;
gap: 2px;
}
.theme-toggle button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
color: var(--ink-soft);
opacity: 0.5;
transition: all 0.2s ease;
}
.theme-toggle button:hover {
opacity: 0.8;
}
.theme-toggle button[data-state="on"] {
opacity: 1;
color: var(--ink);
}
.user-trigger {
@@ -288,6 +312,7 @@ code {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -595,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;
@@ -641,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;
@@ -1217,12 +1357,14 @@ code {
appearance: none;
border: 1px solid var(--border-ui);
border-radius: var(--radius-pill);
padding: 10px 14px;
background: rgba(255, 255, 255, 0.45);
padding: 10px 20px 10px 14px;
background: rgba(255, 255, 255, 0.45) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%236b5549' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 7px center;
background-size: 10px;
color: var(--ink);
font-weight: 650;
font-size: 0.9rem;
cursor: pointer;
width: fit-content;
}
[data-theme="dark"] .skills-sort {
@@ -1270,6 +1412,119 @@ code {
background: var(--surface);
}
.skills-table {
border-radius: var(--radius-md);
border: 1px solid var(--line);
overflow: hidden;
background: var(--surface);
}
.skills-table-header {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 10px 18px;
font-size: 0.78rem;
font-weight: 650;
color: var(--ink-soft);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--line);
background: rgba(42, 31, 25, 0.03);
}
.skills-table-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 12px 18px;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
align-items: center;
}
.skills-table-row:last-child {
border-bottom: none;
}
.skills-table-row:hover {
background: rgba(255, 107, 74, 0.07);
}
[data-theme="dark"] .skills-table-row:hover {
background: rgba(232, 106, 71, 0.11);
}
[data-theme="dark"] .skills-table-header {
background: rgba(255, 255, 255, 0.03);
}
.skills-table-name {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.01em;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.skills-table-name > span:first-child {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
word-break: break-word;
}
.skills-table-summary {
color: var(--ink-soft);
font-size: 0.85rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
min-width: 0;
}
.skills-table-author {
font-size: 0.85rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-table-stats {
display: flex;
gap: 10px;
font-size: 0.82rem;
color: var(--ink-soft);
white-space: nowrap;
}
.skills-table-version {
font-size: 0.85rem;
color: var(--ink-soft);
}
@media (max-width: 860px) {
.skills-table-header,
.skills-table-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) 100px;
}
.skills-table-stats {
display: none;
}
.skills-table-header span:last-child {
display: none;
}
}
.skills-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1386,6 +1641,7 @@ code {
.skill-card {
min-height: 176px;
box-shadow: 0 1px 3px rgba(42, 31, 25, 0.08);
}
.skill-card-tags {
@@ -3370,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;
@@ -3429,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;
@@ -3479,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;
}