From 6aab4f94371f80c5d6f265dd1793c5fe8a6aca86 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:04:26 +0800 Subject: [PATCH] feat(catalog): publish verified organization skills feed --- convex/catalogFeed.test.ts | 107 ++++++++++++++- convex/catalogFeed.ts | 153 ++++++++++++++++++++-- convex/http.ts | 7 + convex/httpApiV1.catalogFeed.test.ts | 4 +- convex/httpApiV1.ts | 4 +- convex/httpApiV1/catalogFeedV1.ts | 13 +- packages/schema/dist/catalogFeed.d.ts | 82 +++++++++++- packages/schema/dist/catalogFeed.js | 14 +- packages/schema/dist/catalogFeed.js.map | 2 +- packages/schema/dist/routes.d.ts | 1 + packages/schema/dist/routes.js | 1 + packages/schema/dist/routes.js.map | 2 +- packages/schema/src/catalogFeed.test.ts | 29 ++++ packages/schema/src/catalogFeed.ts | 22 +++- packages/schema/src/routes.ts | 1 + public/.well-known/openclaw-registry.json | 3 +- public/robots.txt | 1 + specs/deploy.md | 14 +- specs/hosted-catalog-feed.md | 36 +++-- src/components/MarkdownPreview.tsx | 25 +++- vercel.json | 9 ++ vite.config.ts | 3 + 22 files changed, 477 insertions(+), 56 deletions(-) diff --git a/convex/catalogFeed.test.ts b/convex/catalogFeed.test.ts index 885a3332..a5f93ff0 100644 --- a/convex/catalogFeed.test.ts +++ b/convex/catalogFeed.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { listOfficialEntries } from "./catalogFeed"; +import { listOfficialEntries, listOfficialSkillEntries } from "./catalogFeed"; vi.mock("./lib/publishers", () => ({ getOwnerPublisher: vi.fn().mockResolvedValue({ handle: "openclaw" }), @@ -18,6 +18,9 @@ const listOfficialEntriesHandler = ( unknown[] > )._handler; +const listOfficialSkillEntriesHandler = ( + listOfficialSkillEntries as unknown as WrappedHandler<{ cursor: string | null }, unknown> +)._handler; function makePackage(overrides: Record = {}) { return { @@ -49,7 +52,33 @@ function makeRelease(overrides: Record = {}) { }; } -function makeCtx(packages: unknown[], releases: Record) { +function makeSkill(overrides: Record = {}) { + return { + _id: "skills:1", + slug: "demo", + displayName: "Demo skill", + ownerUserId: "users:1", + ownerPublisherId: "publishers:1", + latestVersionId: "skillVersions:1", + softDeletedAt: undefined, + moderationStatus: "active", + ...overrides, + }; +} + +function makeSkillVersion(overrides: Record = {}) { + return { + _id: "skillVersions:1", + skillId: "skills:1", + version: "1.2.3", + softDeletedAt: undefined, + files: [{ path: "SKILL.md", size: 1, storageId: "storage:1", sha256: "file-hash" }], + sha256hash: "skill-hash", + ...overrides, + }; +} + +function makeCtx(packages: unknown[], records: Record) { return { db: { query: vi.fn(() => { @@ -71,7 +100,7 @@ function makeCtx(packages: unknown[], releases: Record) { }), }; }), - get: vi.fn(async (id: string) => releases[id] ?? null), + get: vi.fn(async (id: string) => records[id] ?? null), }, }; } @@ -156,4 +185,76 @@ describe("catalog feed projection", () => { expect(result).toEqual([]); }); + + it("projects only published skills from verified organization publishers", async () => { + const result = (await listOfficialSkillEntriesHandler( + makeCtx([makeSkill()], { + "publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" }, + "skillVersions:1": makeSkillVersion(), + }), + { cursor: null }, + )) as { entries: unknown[]; isDone: boolean }; + + expect(result).toMatchObject({ + entries: [ + { + type: "skill", + id: "@openclaw/demo", + title: "Demo skill", + version: "1.2.3", + state: "available", + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@openclaw/demo", + version: "1.2.3", + integrity: "sha256:skill-hash", + }, + ], + }, + }, + ], + isDone: true, + }); + }); + + it("excludes a latest version blocked by the download safety gate", async () => { + const result = (await listOfficialSkillEntriesHandler( + makeCtx([makeSkill()], { + "publishers:1": { _id: "publishers:1", kind: "org", handle: "openclaw" }, + "skillVersions:1": makeSkillVersion({ + llmAnalysis: { status: "complete", verdict: "malicious" }, + }), + }), + { cursor: null }, + )) as { entries: unknown[]; isDone: boolean }; + + expect(result.entries).toEqual([]); + }); + + it("excludes personal, unverified, unpublished, and un-hashed skills", async () => { + const skills = [ + makeSkill({ _id: "skills:user", ownerPublisherId: "publishers:user" }), + makeSkill({ _id: "skills:unverified", ownerPublisherId: "publishers:unverified" }), + makeSkill({ _id: "skills:unpublished", latestVersionId: undefined }), + makeSkill({ _id: "skills:no-hash", latestVersionId: "skillVersions:no-hash" }), + ]; + vi.mocked((await import("./lib/officialPublishers")).isOfficialPublisher).mockImplementation( + async (_ctx, publisher) => publisher?._id === "publishers:1", + ); + + const result = (await listOfficialSkillEntriesHandler( + makeCtx(skills, { + "publishers:user": { _id: "publishers:user", kind: "user", handle: "alice" }, + "publishers:unverified": { _id: "publishers:unverified", kind: "org", handle: "vendor" }, + "skillVersions:1": makeSkillVersion(), + "skillVersions:no-hash": makeSkillVersion({ sha256hash: undefined }), + }), + { cursor: null }, + )) as { entries: unknown[]; isDone: boolean }; + + expect(result.entries).toEqual([]); + }); }); diff --git a/convex/catalogFeed.ts b/convex/catalogFeed.ts index 51e9d7f4..2a9167bc 100644 --- a/convex/catalogFeed.ts +++ b/convex/catalogFeed.ts @@ -2,8 +2,11 @@ import { CATALOG_FEED_ID, CATALOG_FEED_SCHEMA_VERSION, CATALOG_FEED_SOURCE_REF, + CATALOG_SKILLS_FEED_DESCRIPTION, + CATALOG_SKILLS_FEED_ID, serializeCatalogFeed, type CatalogFeedEntry, + type CatalogFeedSkillEntry, } from "clawhub-schema"; import { v } from "convex/values"; import { internal } from "./_generated/api"; @@ -11,10 +14,16 @@ import type { Doc } from "./_generated/dataModel"; import { internalAction, internalMutation, internalQuery } from "./_generated/server"; import type { QueryCtx } from "./_generated/server"; import { sha256Hex } from "./lib/clawpack"; +import { isPublicSkillDoc } from "./lib/globalStats"; import { isOfficialPublisher } from "./lib/officialPublishers"; import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts"; import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity"; import { getOwnerPublisher } from "./lib/publishers"; +import { + getPublicSkillVersionDownloadBlock, + getSkillFileModerationInfoFromSkill, + isPublicSkillVersionAvailableForSkill, +} from "./lib/skillFileAccess"; const CATALOG_FEED_DESCRIPTION = "Official OpenClaw plugins published on ClawHub."; const CATALOG_FEED_PAGE_SIZE = 100; @@ -31,8 +40,7 @@ type CatalogFeedPublicationResult = { entryCount: number; }; -const catalogFeedEntryValidator = v.object({ - type: v.literal("plugin"), +const catalogFeedEntryFields = { id: v.string(), title: v.string(), version: v.string(), @@ -57,7 +65,11 @@ const catalogFeedEntryValidator = v.object({ }), ), }), -}); +}; +const catalogFeedEntryValidator = v.union( + v.object({ type: v.literal("plugin"), ...catalogFeedEntryFields }), + v.object({ type: v.literal("skill"), ...catalogFeedEntryFields }), +); async function buildEntry( ctx: CatalogQueryCtx, @@ -139,6 +151,73 @@ async function listFamilyEntries( } } +async function buildSkillEntry( + ctx: CatalogQueryCtx, + skill: Doc<"skills">, +): Promise { + if (!isPublicSkillDoc(skill) || !skill.ownerPublisherId || !skill.latestVersionId) return null; + + const [owner, version] = await Promise.all([ + ctx.db.get(skill.ownerPublisherId), + ctx.db.get(skill.latestVersionId), + ]); + if ( + !owner || + owner.kind !== "org" || + !(await isOfficialPublisher(ctx, owner)) || + !version || + !isPublicSkillVersionAvailableForSkill(version, skill._id) || + getPublicSkillVersionDownloadBlock(getSkillFileModerationInfoFromSkill(skill), version) || + !version.files.length || + !version.sha256hash + ) { + return null; + } + + const publisherId = owner.handle?.trim(); + const slug = skill.slug.trim(); + const title = skill.displayName.trim() || slug; + const versionName = version.version.trim(); + if (!publisherId || !slug || !title || !versionName) return null; + + const packageName = `@${publisherId}/${slug}`; + return { + type: "skill", + id: packageName, + title, + version: versionName, + state: "available", + publisher: { + id: publisherId, + trust: "official", + }, + install: { + candidates: [ + { + sourceRef: CATALOG_FEED_SOURCE_REF, + package: packageName, + version: versionName, + integrity: `sha256:${version.sha256hash}`, + }, + ], + }, + }; +} + +async function listOfficialSkillFeedEntries(ctx: CatalogQueryCtx, cursor: string | null) { + const page = await ctx.db + .query("skills") + .withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined)) + .order("desc") + .paginate({ cursor, numItems: CATALOG_FEED_PAGE_SIZE }); + const entries: CatalogFeedSkillEntry[] = []; + for (const skill of page.page) { + const entry = await buildSkillEntry(ctx, skill); + if (entry) entries.push(entry); + } + return { entries, isDone: page.isDone, continueCursor: page.continueCursor }; +} + export const listOfficialEntries = internalQuery({ args: { family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")), @@ -146,31 +225,44 @@ export const listOfficialEntries = internalQuery({ handler: async (ctx, args) => await listFamilyEntries(ctx, args.family), }); +export const listOfficialSkillEntries = internalQuery({ + args: { + cursor: v.union(v.string(), v.null()), + }, + handler: async (ctx, args) => await listOfficialSkillFeedEntries(ctx, args.cursor), +}); + export const storePublication = internalMutation({ args: { + feedId: v.union(v.literal(CATALOG_FEED_ID), v.literal(CATALOG_SKILLS_FEED_ID)), + description: v.string(), generatedAt: v.string(), expiresAt: v.string(), entries: v.array(catalogFeedEntryValidator), }, handler: async (ctx, args) => { + const expectedEntryType = args.feedId === CATALOG_SKILLS_FEED_ID ? "skill" : "plugin"; + if (args.entries.some((entry) => entry.type !== expectedEntryType)) { + throw new Error(`Catalog ${expectedEntryType} feed received a mismatched entry type`); + } const latest = await ctx.db .query("catalogFeedPublications") - .withIndex("by_feed", (q) => q.eq("feedId", CATALOG_FEED_ID)) + .withIndex("by_feed", (q) => q.eq("feedId", args.feedId)) .unique(); const sequence = (latest?.sequence ?? 0) + 1; const payload = serializeCatalogFeed({ schemaVersion: CATALOG_FEED_SCHEMA_VERSION, - id: CATALOG_FEED_ID, + id: args.feedId, generatedAt: args.generatedAt, sequence, expiresAt: args.expiresAt, - description: CATALOG_FEED_DESCRIPTION, + description: args.description, entries: args.entries, }); const payloadSha256 = await sha256Hex(new TextEncoder().encode(payload)); const publishedAt = Date.now(); const publication = { - feedId: CATALOG_FEED_ID, + feedId: args.feedId, sequence, generatedAt: args.generatedAt, expiresAt: args.expiresAt, @@ -183,7 +275,7 @@ export const storePublication = internalMutation({ : await ctx.db.insert("catalogFeedPublications", publication); return { publicationId, - feedId: CATALOG_FEED_ID, + feedId: args.feedId, sequence, payloadSha256, publishedAt, @@ -196,7 +288,7 @@ export const publish = internalAction({ args: { expiresAt: v.string(), }, - handler: async (ctx, args): Promise => { + handler: async (ctx, args): Promise => { const generatedAt = new Date().toISOString(); const familyEntries: CatalogFeedEntry[][] = await Promise.all( CATALOG_FEED_FAMILIES.map(async (family) => { @@ -211,23 +303,56 @@ export const publish = internalAction({ if (entries.length > MAX_CATALOG_FEED_ENTRIES) { throw new Error(`Catalog feed exceeds ${MAX_CATALOG_FEED_ENTRIES} entries`); } - const result: CatalogFeedPublicationResult = await ctx.runMutation( + const skillEntries: CatalogFeedSkillEntry[] = []; + let skillCursor: string | null = null; + let skillPageDone = false; + while (!skillPageDone) { + const skillPage: { + entries: CatalogFeedSkillEntry[]; + isDone: boolean; + continueCursor: string; + } = await ctx.runQuery(internal.catalogFeed.listOfficialSkillEntries, { + cursor: skillCursor, + }); + skillEntries.push(...skillPage.entries); + if (skillEntries.length > MAX_CATALOG_FEED_ENTRIES) { + throw new Error(`Catalog skills feed exceeds ${MAX_CATALOG_FEED_ENTRIES} entries`); + } + skillPageDone = skillPage.isDone; + skillCursor = skillPage.continueCursor; + } + + const pluginResult: CatalogFeedPublicationResult = await ctx.runMutation( internal.catalogFeed.storePublication, { + feedId: CATALOG_FEED_ID, + description: CATALOG_FEED_DESCRIPTION, generatedAt, expiresAt: args.expiresAt, entries: entries.sort((left, right) => left.id.localeCompare(right.id)), }, ); - return result; + const skillsResult: CatalogFeedPublicationResult = await ctx.runMutation( + internal.catalogFeed.storePublication, + { + feedId: CATALOG_SKILLS_FEED_ID, + description: CATALOG_SKILLS_FEED_DESCRIPTION, + generatedAt, + expiresAt: args.expiresAt, + entries: skillEntries.sort((left, right) => left.id.localeCompare(right.id)), + }, + ); + return [pluginResult, skillsResult]; }, }); export const getLatestPublication = internalQuery({ - args: {}, - handler: async (ctx) => + args: { + feedId: v.union(v.literal(CATALOG_FEED_ID), v.literal(CATALOG_SKILLS_FEED_ID)), + }, + handler: async (ctx, args) => await ctx.db .query("catalogFeedPublications") - .withIndex("by_feed", (q) => q.eq("feedId", CATALOG_FEED_ID)) + .withIndex("by_feed", (q) => q.eq("feedId", args.feedId)) .unique(), }); diff --git a/convex/http.ts b/convex/http.ts index 2a0f36c5..ac0c4347 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -47,6 +47,7 @@ import { transfersGetRouterV1Http, banAppealContextV1Http, catalogFeedV1Http, + catalogSkillsFeedV1Http, usersGetRouterV1Http, usersListV1Http, usersPostRouterV1Http, @@ -137,6 +138,12 @@ http.route({ handler: catalogFeedV1Http, }); +http.route({ + path: ApiRoutes.catalogSkillsFeed, + method: "GET", + handler: catalogSkillsFeedV1Http, +}); + http.route({ pathPrefix: `${ApiRoutes.skills}/`, method: "GET", diff --git a/convex/httpApiV1.catalogFeed.test.ts b/convex/httpApiV1.catalogFeed.test.ts index 77058df2..a66199ea 100644 --- a/convex/httpApiV1.catalogFeed.test.ts +++ b/convex/httpApiV1.catalogFeed.test.ts @@ -35,7 +35,9 @@ describe("catalogFeedV1Handler", () => { expect(response.headers.get("last-modified")).toBe("Tue, 23 Jun 2026 00:00:00 GMT"); expect(response.headers.get("cache-control")).toContain("s-maxage=300"); expect(response.headers.get("surrogate-control")).toContain("stale-while-revalidate=86400"); - expect(ctx.runQuery).toHaveBeenCalledWith(internal.catalogFeed.getLatestPublication, {}); + expect(ctx.runQuery).toHaveBeenCalledWith(internal.catalogFeed.getLatestPublication, { + feedId: "clawhub-official", + }); }); it("returns 304 for a matching validator", async () => { diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index ee7c9226..d215828c 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -1,5 +1,5 @@ import { httpAction } from "./functions"; -import { catalogFeedV1Handler } from "./httpApiV1/catalogFeedV1"; +import { catalogFeedV1Handler, catalogSkillsFeedV1Handler } from "./httpApiV1/catalogFeedV1"; import { contentRightsV1Handler } from "./httpApiV1/contentRightsV1"; import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1"; import { @@ -58,6 +58,7 @@ export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler); export const createPublisherV1Http = httpAction(createPublisherV1Handler); export const contentRightsV1Http = httpAction(contentRightsV1Handler); export const catalogFeedV1Http = httpAction(catalogFeedV1Handler); +export const catalogSkillsFeedV1Http = httpAction(catalogSkillsFeedV1Handler); export const searchSkillsV1Http = httpAction(searchSkillsV1Handler); export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler); @@ -100,6 +101,7 @@ export const __handlers = { createPublisherV1Handler, contentRightsV1Handler, catalogFeedV1Handler, + catalogSkillsFeedV1Handler, searchSkillsV1Handler, resolveSkillVersionV1Handler, listSkillsV1Handler, diff --git a/convex/httpApiV1/catalogFeedV1.ts b/convex/httpApiV1/catalogFeedV1.ts index 94bb1a6e..c19e6815 100644 --- a/convex/httpApiV1/catalogFeedV1.ts +++ b/convex/httpApiV1/catalogFeedV1.ts @@ -1,3 +1,4 @@ +import { CATALOG_FEED_ID, CATALOG_SKILLS_FEED_ID } from "clawhub-schema"; import { internal } from "../_generated/api"; import type { ActionCtx } from "../_generated/server"; import { corsHeaders, mergeHeaders } from "../lib/httpHeaders"; @@ -23,8 +24,12 @@ function matchesLastModified(request: Request, publishedAt: number) { return Math.floor(publishedAt / 1000) * 1000 <= since; } -export async function catalogFeedV1Handler(ctx: ActionCtx, request: Request) { - const publication = await ctx.runQuery(internal.catalogFeed.getLatestPublication, {}); +export async function catalogFeedV1Handler( + ctx: ActionCtx, + request: Request, + feedId: typeof CATALOG_FEED_ID | typeof CATALOG_SKILLS_FEED_ID = CATALOG_FEED_ID, +) { + const publication = await ctx.runQuery(internal.catalogFeed.getLatestPublication, { feedId }); if (!publication) { return new Response("Catalog feed is not published", { status: 503, @@ -62,3 +67,7 @@ export async function catalogFeedV1Handler(ctx: ActionCtx, request: Request) { } return new Response(publication.payload, { status: 200, headers }); } + +export async function catalogSkillsFeedV1Handler(ctx: ActionCtx, request: Request) { + return await catalogFeedV1Handler(ctx, request, CATALOG_SKILLS_FEED_ID); +} diff --git a/packages/schema/dist/catalogFeed.d.ts b/packages/schema/dist/catalogFeed.d.ts index 26a6cb36..f149a090 100644 --- a/packages/schema/dist/catalogFeed.d.ts +++ b/packages/schema/dist/catalogFeed.d.ts @@ -10,6 +10,46 @@ export declare const CatalogFeedInstallCandidateSchema: import("arktype/internal integrity: string; }, {}>; export type CatalogFeedInstallCandidate = (typeof CatalogFeedInstallCandidateSchema)[inferred]; +export declare const CatalogFeedPluginEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{ + type: "plugin"; + id: string; + title: string; + version: string; + state: "available" | "recommended" | "disabled" | "blocked" | "deprecated"; + publisher: { + id: string; + trust: "official" | "community"; + }; + install: { + candidates: { + sourceRef: string; + package: string; + version: string; + integrity: string; + }[]; + }; +}, {}>; +export type CatalogFeedPluginEntry = (typeof CatalogFeedPluginEntrySchema)[inferred]; +export declare const CatalogFeedSkillEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{ + type: "skill"; + id: string; + title: string; + version: string; + state: "available" | "recommended" | "disabled" | "blocked" | "deprecated"; + publisher: { + id: string; + trust: "official" | "community"; + }; + install: { + candidates: { + sourceRef: string; + package: string; + version: string; + integrity: string; + }[]; + }; +}, {}>; +export type CatalogFeedSkillEntry = (typeof CatalogFeedSkillEntrySchema)[inferred]; export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{ type: "plugin"; id: string; @@ -28,6 +68,24 @@ export declare const CatalogFeedEntrySchema: import("arktype/internal/variants/o integrity: string; }[]; }; +} | { + type: "skill"; + id: string; + title: string; + version: string; + state: "available" | "recommended" | "disabled" | "blocked" | "deprecated"; + publisher: { + id: string; + trust: "official" | "community"; + }; + install: { + candidates: { + sourceRef: string; + package: string; + version: string; + integrity: string; + }[]; + }; }, {}>; export type CatalogFeedEntry = (typeof CatalogFeedEntrySchema)[inferred]; export declare const CatalogFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{ @@ -36,7 +94,7 @@ export declare const CatalogFeedSchema: import("arktype/internal/variants/object generatedAt: string; sequence: number; expiresAt: string; - entries: { + entries: ({ type: "plugin"; id: string; title: string; @@ -54,12 +112,32 @@ export declare const CatalogFeedSchema: import("arktype/internal/variants/object integrity: string; }[]; }; - }[]; + } | { + type: "skill"; + id: string; + title: string; + version: string; + state: "available" | "recommended" | "disabled" | "blocked" | "deprecated"; + publisher: { + id: string; + trust: "official" | "community"; + }; + install: { + candidates: { + sourceRef: string; + package: string; + version: string; + integrity: string; + }[]; + }; + })[]; description?: string | undefined; }, {}>; export type CatalogFeed = (typeof CatalogFeedSchema)[inferred]; export declare const CATALOG_FEED_SCHEMA_VERSION = 1; export declare const CATALOG_FEED_ID = "clawhub-official"; export declare const CATALOG_FEED_SOURCE_REF = "public-clawhub"; +export declare const CATALOG_SKILLS_FEED_ID = "clawhub-official-skills"; +export declare const CATALOG_SKILLS_FEED_DESCRIPTION = "Skills published by verified OpenClaw organizations on ClawHub."; export declare function parseCatalogFeed(value: unknown): CatalogFeed; export declare function serializeCatalogFeed(feed: CatalogFeed): string; diff --git a/packages/schema/dist/catalogFeed.js b/packages/schema/dist/catalogFeed.js index 06a163c0..b9161507 100644 --- a/packages/schema/dist/catalogFeed.js +++ b/packages/schema/dist/catalogFeed.js @@ -8,9 +8,8 @@ export const CatalogFeedInstallCandidateSchema = type({ version: "string", integrity: "string", }); -export const CatalogFeedEntrySchema = type({ +const CatalogFeedEntryBaseSchema = { "+": "reject", - type: '"plugin"', id: "string", title: "string", version: "string", @@ -24,7 +23,16 @@ export const CatalogFeedEntrySchema = type({ "+": "reject", candidates: CatalogFeedInstallCandidateSchema.array(), }, +}; +export const CatalogFeedPluginEntrySchema = type({ + ...CatalogFeedEntryBaseSchema, + type: '"plugin"', }); +export const CatalogFeedSkillEntrySchema = type({ + ...CatalogFeedEntryBaseSchema, + type: '"skill"', +}); +export const CatalogFeedEntrySchema = type(CatalogFeedPluginEntrySchema.or(CatalogFeedSkillEntrySchema)); export const CatalogFeedSchema = type({ "+": "reject", schemaVersion: "number", @@ -38,6 +46,8 @@ export const CatalogFeedSchema = type({ export const CATALOG_FEED_SCHEMA_VERSION = 1; export const CATALOG_FEED_ID = "clawhub-official"; export const CATALOG_FEED_SOURCE_REF = "public-clawhub"; +export const CATALOG_SKILLS_FEED_ID = "clawhub-official-skills"; +export const CATALOG_SKILLS_FEED_DESCRIPTION = "Skills published by verified OpenClaw organizations on ClawHub."; export function parseCatalogFeed(value) { const feed = CatalogFeedSchema.assert(value); if (feed.schemaVersion !== CATALOG_FEED_SCHEMA_VERSION) { diff --git a/packages/schema/dist/catalogFeed.js.map b/packages/schema/dist/catalogFeed.js.map index 3a6f591f..7bac4381 100644 --- a/packages/schema/dist/catalogFeed.js.map +++ b/packages/schema/dist/catalogFeed.js.map @@ -1 +1 @@ -{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 2bd499c3..2fccee42 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -23,6 +23,7 @@ export declare const ApiRoutes: { readonly codePlugins: "/api/v1/code-plugins"; readonly bundlePlugins: "/api/v1/bundle-plugins"; readonly catalogFeed: "/api/v1/feeds/plugins"; + readonly catalogSkillsFeed: "/api/v1/feeds/skills"; readonly stars: "/api/v1/stars"; readonly transfers: "/api/v1/transfers"; readonly publishers: "/api/v1/publishers"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 870dc5a2..835584a6 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -23,6 +23,7 @@ export const ApiRoutes = { codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", catalogFeed: "/api/v1/feeds/plugins", + catalogSkillsFeed: "/api/v1/feeds/skills", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map index 68a6491e..73a535f4 100644 --- a/packages/schema/dist/routes.js.map +++ b/packages/schema/dist/routes.js.map @@ -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,mBAAmB,EAAE,4BAA4B;IACjD,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,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,WAAW,EAAE,uBAAuB;IACpC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file +{"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,mBAAmB,EAAE,4BAA4B;IACjD,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,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/catalogFeed.test.ts b/packages/schema/src/catalogFeed.test.ts index b5d5eb4c..f6f0044d 100644 --- a/packages/schema/src/catalogFeed.test.ts +++ b/packages/schema/src/catalogFeed.test.ts @@ -3,6 +3,7 @@ import { CATALOG_FEED_ID, CATALOG_FEED_SCHEMA_VERSION, CATALOG_FEED_SOURCE_REF, + CATALOG_SKILLS_FEED_ID, parseCatalogFeed, serializeCatalogFeed, type CatalogFeed, @@ -115,4 +116,32 @@ describe("catalog feed schema", () => { ), ).toThrow(); }); + + it("accepts skill entries in a skills feed", () => { + const feed = makeFeed({ + id: CATALOG_SKILLS_FEED_ID, + entries: [ + { + type: "skill", + id: "@openclaw/demo", + title: "Demo", + version: "1.0.0", + state: "available", + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: CATALOG_FEED_SOURCE_REF, + package: "@openclaw/demo", + version: "1.0.0", + integrity: "sha256:abc", + }, + ], + }, + }, + ], + }); + + expect(parseCatalogFeed(feed).entries[0]?.type).toBe("skill"); + }); }); diff --git a/packages/schema/src/catalogFeed.ts b/packages/schema/src/catalogFeed.ts index 97b96987..542d3e44 100644 --- a/packages/schema/src/catalogFeed.ts +++ b/packages/schema/src/catalogFeed.ts @@ -17,9 +17,8 @@ export const CatalogFeedInstallCandidateSchema = type({ }); export type CatalogFeedInstallCandidate = (typeof CatalogFeedInstallCandidateSchema)[inferred]; -export const CatalogFeedEntrySchema = type({ +const CatalogFeedEntryBaseSchema = { "+": "reject", - type: '"plugin"', id: "string", title: "string", version: "string", @@ -33,7 +32,23 @@ export const CatalogFeedEntrySchema = type({ "+": "reject", candidates: CatalogFeedInstallCandidateSchema.array(), }, +} as const; + +export const CatalogFeedPluginEntrySchema = type({ + ...CatalogFeedEntryBaseSchema, + type: '"plugin"', }); +export type CatalogFeedPluginEntry = (typeof CatalogFeedPluginEntrySchema)[inferred]; + +export const CatalogFeedSkillEntrySchema = type({ + ...CatalogFeedEntryBaseSchema, + type: '"skill"', +}); +export type CatalogFeedSkillEntry = (typeof CatalogFeedSkillEntrySchema)[inferred]; + +export const CatalogFeedEntrySchema = type( + CatalogFeedPluginEntrySchema.or(CatalogFeedSkillEntrySchema), +); export type CatalogFeedEntry = (typeof CatalogFeedEntrySchema)[inferred]; export const CatalogFeedSchema = type({ @@ -51,6 +66,9 @@ export type CatalogFeed = (typeof CatalogFeedSchema)[inferred]; export const CATALOG_FEED_SCHEMA_VERSION = 1; export const CATALOG_FEED_ID = "clawhub-official"; export const CATALOG_FEED_SOURCE_REF = "public-clawhub"; +export const CATALOG_SKILLS_FEED_ID = "clawhub-official-skills"; +export const CATALOG_SKILLS_FEED_DESCRIPTION = + "Skills published by verified OpenClaw organizations on ClawHub."; export function parseCatalogFeed(value: unknown): CatalogFeed { const feed = CatalogFeedSchema.assert(value); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index bd9b775b..9c0e803c 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -24,6 +24,7 @@ export const ApiRoutes = { codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", catalogFeed: "/api/v1/feeds/plugins", + catalogSkillsFeed: "/api/v1/feeds/skills", stars: "/api/v1/stars", transfers: "/api/v1/transfers", publishers: "/api/v1/publishers", diff --git a/public/.well-known/openclaw-registry.json b/public/.well-known/openclaw-registry.json index 8edd1567..28b6ae38 100644 --- a/public/.well-known/openclaw-registry.json +++ b/public/.well-known/openclaw-registry.json @@ -3,6 +3,7 @@ "schemaVersion": 1, "registry": "https://registry.openclaw.ai", "feeds": { - "plugins": "https://registry.openclaw.ai/v1/feeds/plugins" + "plugins": "https://registry.openclaw.ai/v1/feeds/plugins", + "skills": "https://registry.openclaw.ai/v1/feeds/skills" } } diff --git a/public/robots.txt b/public/robots.txt index fde38cf8..b573f400 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -2,3 +2,4 @@ User-agent: * Disallow: /api/ Disallow: /admin/ Allow: /v1/feeds/plugins +Allow: /v1/feeds/skills diff --git a/specs/deploy.md b/specs/deploy.md index 10ad6a50..1f50f51f 100644 --- a/specs/deploy.md +++ b/specs/deploy.md @@ -171,18 +171,18 @@ Keep production rewrites and discovery metadata aligned before release. ### Hosted catalog feed -Refresh the OpenClaw hosted plugin feed after the production Convex deployment -has the catalog projection: +Refresh the OpenClaw hosted plugin and skill feeds after the production Convex +deployment has the catalog projections: ```bash gh workflow run publish-catalog-feed.yml --repo openclaw/clawhub --ref main ``` -The workflow stores the current feed snapshot in Convex and serves it through -`/v1/feeds/plugins` with public edge-cache validators. The unversioned -`/feeds/plugins` path redirects to the versioned route. Attach -`registry.openclaw.ai` to the same Vercel project before configuring OpenClaw's -default feed URL. +The workflow stores both current feed snapshots in Convex and serves them +through `/v1/feeds/plugins` and `/v1/feeds/skills` with public edge-cache +validators. The unversioned `/feeds/plugins` and `/feeds/skills` paths redirect +to their versioned routes. Attach `registry.openclaw.ai` to the same Vercel +project before configuring OpenClaw's default feed URLs. ## 5) Post-deploy checks diff --git a/specs/hosted-catalog-feed.md b/specs/hosted-catalog-feed.md index 611b8576..942d5429 100644 --- a/specs/hosted-catalog-feed.md +++ b/specs/hosted-catalog-feed.md @@ -8,9 +8,9 @@ read_when: # Hosted Catalog Feed -ClawHub is the canonical producer for the initial OpenClaw plugin feed. The -feed is a projection of the existing public package and release records; it is -not a second package catalog. +ClawHub is the canonical producer for the initial OpenClaw plugin and skill +feeds. The feeds are projections of the existing public package, release, and +skill records; they are not second catalogs. ## Contract @@ -36,12 +36,19 @@ feeds stack must add its RFC-entry adapter before `registry.openclaw.ai` is enabled as the default client feed; publishing this snapshot is otherwise safe, but pre-adapter clients will fall back to their bundled catalog. +The skills feed uses the same envelope and `/v1/feeds/skills` route. It emits +`type: "skill"` entries with `@/` ids and ClawHub install +coordinates. It includes only skills with an active latest published version, +non-empty files, a SHA-256 integrity hash, and an active official publisher +whose publisher kind is `org`. Personal publishers and unverified +organizations are excluded. + ## Publication -`convex/catalogFeed.ts` builds the feed from indexed package queries and stores -one current publication row in `catalogFeedPublications`. Keeping one row avoids -an unbounded publication log while preserving the sequence and exact payload -needed for validators. +`convex/catalogFeed.ts` builds both feeds from indexed package/skill queries and +stores one current publication row per feed in `catalogFeedPublications`. +Keeping one row per feed avoids an unbounded publication log while preserving +the sequence and exact payload needed for validators. The `Publish Hosted Catalog Feed` workflow refreshes the snapshot every six hours and can be run manually. It requires the existing `Production` environment @@ -51,8 +58,8 @@ advertised to OpenClaw clients until the signing key and trust root are deployed ## Edge delivery -The HTTP endpoint is `/api/v1/feeds/plugins`. It returns the stored bytes -unchanged and provides: +The HTTP endpoints are `/api/v1/feeds/plugins` and `/api/v1/feeds/skills`. Each +returns its stored bytes unchanged and provides: - `ETag: "sha256:"` - `Last-Modified` @@ -60,17 +67,18 @@ unchanged and provides: - `Surrogate-Control: max-age=300, stale-while-revalidate=86400` - `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since` -`vercel.json` exposes `/v1/feeds/plugins` as an edge-friendly rewrite to the -Convex endpoint. The unversioned `/feeds/plugins` path permanently redirects -to the versioned path. The `registry.openclaw.ai` custom domain must point at -the same Vercel project before the public RFC URL is enabled. +`vercel.json` exposes both `/v1/feeds/plugins` and `/v1/feeds/skills` as +edge-friendly rewrites to the Convex endpoints. The unversioned `/feeds/*` +paths permanently redirect to their versioned paths. The +`registry.openclaw.ai` custom domain must point at the same Vercel project +before the public RFC URLs are enabled. The serialized payload uses stable object-key ordering and deterministic entry and install-candidate ordering. Additive fields may be introduced within a major version; incompatible wire changes require a new versioned route and schema version. -`/.well-known/openclaw-registry.json` advertises the versioned plugin feed. +`/.well-known/openclaw-registry.json` advertises both versioned feeds. `/.well-known/clawhub.json` remains the ClawHub API discovery document. Do not make the feed request-time dynamic. Refresh the stored publication first, diff --git a/src/components/MarkdownPreview.tsx b/src/components/MarkdownPreview.tsx index 5f43e2a0..1d2766a0 100644 --- a/src/components/MarkdownPreview.tsx +++ b/src/components/MarkdownPreview.tsx @@ -107,11 +107,26 @@ let highlighterPromise: Promise | null = null; function loadHighlighter(): Promise { if (!highlighterPromise) { - highlighterPromise = import("shiki").then(({ createHighlighter }) => - createHighlighter({ - themes: [...SHIKI_THEMES], - langs: SHIKI_LANGS, - }), + highlighterPromise = Promise.all([ + import("shiki/dist/core.mjs"), + import("shiki/dist/engine-javascript.mjs"), + import("shiki/dist/langs.mjs"), + import("shiki/dist/themes.mjs"), + ]).then( + ([ + { createBundledHighlighter }, + { createJavaScriptRegexEngine }, + { bundledLanguages }, + { bundledThemes }, + ]) => + createBundledHighlighter({ + langs: bundledLanguages, + themes: bundledThemes, + engine: () => createJavaScriptRegexEngine(), + })({ + themes: [...SHIKI_THEMES], + langs: SHIKI_LANGS, + }), ); } return highlighterPromise; diff --git a/vercel.json b/vercel.json index 7208dabc..30daf55a 100644 --- a/vercel.json +++ b/vercel.json @@ -26,6 +26,11 @@ "destination": "/v1/feeds/plugins", "statusCode": 308 }, + { + "source": "/feeds/skills", + "destination": "/v1/feeds/skills", + "statusCode": 308 + }, { "source": "/", "has": [ @@ -59,6 +64,10 @@ { "source": "/v1/feeds/plugins", "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins" + }, + { + "source": "/v1/feeds/skills", + "destination": "https://wry-manatee-359.convex.site/api/v1/feeds/skills" } ], "images": { diff --git a/vite.config.ts b/vite.config.ts index c61a11e3..a978504a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -175,6 +175,9 @@ const config = defineConfig({ "convex/browser": convexBrowserPath, "convex/values": convexValuesPath, "@convex-dev/auth/react": convexAuthReactPath, + // MarkdownPreview uses Shiki's JavaScript engine; keep rehype from + // selecting the WASM-only `shiki/core` export condition. + "shiki/core": "shiki/dist/core.mjs", }, // Use native Vite tsconfig paths resolution instead of the plugin tsconfigPaths: true,