feat(catalog): publish verified organization skills feed

This commit is contained in:
Vincent Koc
2026-06-23 17:04:26 +08:00
parent 94f2f532c3
commit 6aab4f9437
22 changed files with 477 additions and 56 deletions
+104 -3
View File
@@ -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<string, unknown> = {}) {
return {
@@ -49,7 +52,33 @@ function makeRelease(overrides: Record<string, unknown> = {}) {
};
}
function makeCtx(packages: unknown[], releases: Record<string, unknown>) {
function makeSkill(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>) {
return {
db: {
query: vi.fn(() => {
@@ -71,7 +100,7 @@ function makeCtx(packages: unknown[], releases: Record<string, unknown>) {
}),
};
}),
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([]);
});
});
+139 -14
View File
@@ -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<CatalogFeedSkillEntry | null> {
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<CatalogFeedPublicationResult> => {
handler: async (ctx, args): Promise<CatalogFeedPublicationResult[]> => {
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(),
});
+7
View File
@@ -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",
+3 -1
View File
@@ -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 () => {
+3 -1
View File
@@ -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,
+11 -2
View File
@@ -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);
}
+80 -2
View File
@@ -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;
+12 -2
View File
@@ -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) {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"}
+1
View File
@@ -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";
+1
View File
@@ -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",
+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,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"}
{"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"}
+29
View File
@@ -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");
});
});
+20 -2
View File
@@ -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);
+1
View File
@@ -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",
+2 -1
View File
@@ -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"
}
}
+1
View File
@@ -2,3 +2,4 @@ User-agent: *
Disallow: /api/
Disallow: /admin/
Allow: /v1/feeds/plugins
Allow: /v1/feeds/skills
+7 -7
View File
@@ -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
+22 -14
View File
@@ -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 `@<publisher>/<slug>` 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:<payload hash>"`
- `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,
+20 -5
View File
@@ -107,11 +107,26 @@ let highlighterPromise: Promise<unknown> | null = null;
function loadHighlighter(): Promise<unknown> {
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;
+9
View File
@@ -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": {
+3
View File
@@ -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,