feat(catalog): publish hosted OpenClaw plugin feed (#2805)

* feat(schema): define hosted catalog feed contract

* feat(catalog): publish cached hosted plugin feed

* feat(catalog): add feed publication and edge delivery

* fix(catalog): recheck live official publisher state

* fix(workflow): require main for catalog publication

* fix(catalog): harden publication inputs

* docs(catalog): document consumer rollout boundary
This commit is contained in:
Vincent Koc
2026-06-23 15:41:24 +08:00
committed by GitHub
parent 775146ff6f
commit 72c5cdd864
26 changed files with 985 additions and 2 deletions
@@ -0,0 +1,67 @@
name: Publish Hosted Catalog Feed
on:
schedule:
- cron: "17 */6 * * *"
workflow_dispatch:
inputs:
expires_in_days:
description: "How long the published feed remains fresh"
required: true
default: "7"
type: string
concurrency:
group: publish-catalog-feed
cancel-in-progress: true
permissions:
contents: read
jobs:
validate-ref:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require main ref for production publication
run: |
set -euo pipefail
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
echo "Production catalog publications must run from main."
exit 1
fi
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate-ref
environment:
name: Production
url: https://registry.openclaw.ai/feeds/plugins
env:
EXPIRES_IN_DAYS: ${{ inputs.expires_in_days || '7' }}
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.3.10
- name: Install
run: bun install --frozen-lockfile
- name: Publish current production catalog
env:
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
run: |
set -euo pipefail
if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then
echo "::error::Missing Production environment secret CONVEX_DEPLOY_KEY"
exit 1
fi
if ! [[ "$EXPIRES_IN_DAYS" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::expires_in_days must be a positive integer"
exit 1
fi
expires_at="$(node -e 'const days = Number(process.env.EXPIRES_IN_DAYS); console.log(new Date(Date.now() + days * 86400000).toISOString())')"
bunx convex run catalogFeed:publish "{\"expiresAt\":\"$expires_at\"}" --prod
+4
View File
@@ -12,6 +12,7 @@ import type * as appMeta from "../appMeta.js";
import type * as auth from "../auth.js";
import type * as catalogClassification from "../catalogClassification.js";
import type * as catalogClassificationNode from "../catalogClassificationNode.js";
import type * as catalogFeed from "../catalogFeed.js";
import type * as catalogTopics from "../catalogTopics.js";
import type * as cliDeviceAuth from "../cliDeviceAuth.js";
import type * as crons from "../crons.js";
@@ -31,6 +32,7 @@ import type * as githubSkillSyncNode from "../githubSkillSyncNode.js";
import type * as http from "../http.js";
import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
import type * as httpApiV1_catalogFeedV1 from "../httpApiV1/catalogFeedV1.js";
import type * as httpApiV1_contentRightsV1 from "../httpApiV1/contentRightsV1.js";
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
@@ -160,6 +162,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
catalogClassification: typeof catalogClassification;
catalogClassificationNode: typeof catalogClassificationNode;
catalogFeed: typeof catalogFeed;
catalogTopics: typeof catalogTopics;
cliDeviceAuth: typeof cliDeviceAuth;
crons: typeof crons;
@@ -179,6 +182,7 @@ declare const fullApi: ApiFromModules<{
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
"httpApiV1/catalogFeedV1": typeof httpApiV1_catalogFeedV1;
"httpApiV1/contentRightsV1": typeof httpApiV1_contentRightsV1;
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { listOfficialEntries } from "./catalogFeed";
vi.mock("./lib/publishers", () => ({
getOwnerPublisher: vi.fn().mockResolvedValue({ handle: "openclaw" }),
}));
vi.mock("./lib/officialPublishers", () => ({
isOfficialPublisher: vi.fn().mockResolvedValue(true),
}));
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const listOfficialEntriesHandler = (
listOfficialEntries as unknown as WrappedHandler<
{ family: "code-plugin" | "bundle-plugin" },
unknown[]
>
)._handler;
function makePackage(overrides: Record<string, unknown> = {}) {
return {
_id: "packages:1",
name: "@openclaw/demo",
normalizedName: "@openclaw/demo",
displayName: "Demo",
ownerUserId: "users:1",
family: "code-plugin",
channel: "official",
isOfficial: true,
latestReleaseId: "packageReleases:1",
softDeletedAt: undefined,
...overrides,
};
}
function makeRelease(overrides: Record<string, unknown> = {}) {
return {
packageId: "packages:1",
version: "1.2.3",
integritySha256: "ignored",
artifactKind: "legacy-zip",
sha256hash: "artifact-hash",
verification: { scanStatus: "clean" },
manualModeration: undefined,
softDeletedAt: undefined,
...overrides,
};
}
function makeCtx(packages: unknown[], releases: Record<string, unknown>) {
return {
db: {
query: vi.fn(() => {
const query = {
eq: vi.fn(() => query),
};
return {
withIndex: vi.fn((_index: string, apply: (value: typeof query) => unknown) => {
apply(query);
return {
order: vi.fn(() => ({
paginate: vi.fn(async () => ({
page: packages,
isDone: true,
continueCursor: "",
})),
})),
};
}),
};
}),
get: vi.fn(async (id: string) => releases[id] ?? null),
},
};
}
describe("catalog feed projection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("projects official releases into ClawHub install candidates", async () => {
const result = await listOfficialEntriesHandler(
makeCtx([makePackage()], {
"packageReleases:1": makeRelease(),
}),
{ family: "code-plugin" },
);
expect(result).toEqual([
{
type: "plugin",
id: "@openclaw/demo",
title: "Demo",
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:artifact-hash",
},
],
},
},
]);
});
it("excludes non-official, blocked, deleted, and undigested releases", async () => {
const result = await listOfficialEntriesHandler(
makeCtx(
[
makePackage({ name: "@openclaw/community", channel: "community" }),
makePackage({ name: "@openclaw/deleted", softDeletedAt: 1 }),
makePackage({ name: "@openclaw/malicious", latestReleaseId: "packageReleases:2" }),
makePackage({ name: "@openclaw/no-hash", latestReleaseId: "packageReleases:3" }),
],
{
"packageReleases:1": makeRelease(),
"packageReleases:2": makeRelease({ manualModeration: { state: "quarantined" } }),
"packageReleases:3": makeRelease({ sha256hash: undefined }),
},
),
{ family: "code-plugin" },
);
expect(result).toEqual([]);
});
it("re-checks the live official publisher record", async () => {
const { isOfficialPublisher } = await import("./lib/officialPublishers");
vi.mocked(isOfficialPublisher).mockResolvedValueOnce(false);
const result = await listOfficialEntriesHandler(
makeCtx([makePackage()], {
"packageReleases:1": makeRelease(),
}),
{ family: "code-plugin" },
);
expect(result).toEqual([]);
});
it("rejects a latest-release pointer for another package", async () => {
const result = await listOfficialEntriesHandler(
makeCtx([makePackage({ _id: "packages:2" })], {
"packageReleases:1": makeRelease(),
}),
{ family: "code-plugin" },
);
expect(result).toEqual([]);
});
});
+233
View File
@@ -0,0 +1,233 @@
import {
CATALOG_FEED_ID,
CATALOG_FEED_SCHEMA_VERSION,
CATALOG_FEED_SOURCE_REF,
serializeCatalogFeed,
type CatalogFeedEntry,
} from "clawhub-schema";
import { v } from "convex/values";
import { internal } from "./_generated/api";
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 { isOfficialPublisher } from "./lib/officialPublishers";
import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts";
import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity";
import { getOwnerPublisher } from "./lib/publishers";
const CATALOG_FEED_DESCRIPTION = "Official OpenClaw plugins published on ClawHub.";
const CATALOG_FEED_PAGE_SIZE = 100;
const MAX_CATALOG_FEED_ENTRIES = 500;
const CATALOG_FEED_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
type CatalogQueryCtx = Pick<QueryCtx, "db">;
type CatalogFeedPublicationResult = {
publicationId: string;
feedId: string;
sequence: number;
payloadSha256: string;
publishedAt: number;
entryCount: number;
};
const catalogFeedEntryValidator = v.object({
type: v.literal("plugin"),
id: v.string(),
title: v.string(),
version: v.string(),
state: v.union(
v.literal("available"),
v.literal("recommended"),
v.literal("disabled"),
v.literal("blocked"),
v.literal("deprecated"),
),
publisher: v.object({
id: v.string(),
trust: v.union(v.literal("official"), v.literal("community")),
}),
install: v.object({
candidates: v.array(
v.object({
sourceRef: v.string(),
package: v.string(),
version: v.string(),
integrity: v.string(),
}),
),
}),
});
async function buildEntry(
ctx: CatalogQueryCtx,
pkg: Doc<"packages">,
): Promise<CatalogFeedEntry | null> {
if (pkg.softDeletedAt || pkg.channel !== "official" || !pkg.latestReleaseId) return null;
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.packageId !== pkg._id || release.softDeletedAt) return null;
// Keep ClawHub on RFC 19's canonical feed entry shape. OpenClaw's staged
// consumer must land its legacy-catalog adapter before this URL is enabled.
const scanStatus = resolvePackageReleaseScanStatus(release);
if (isPackageBlockedFromPublic(scanStatus)) return null;
const artifactSha256 = getPackageReleaseArtifactSha256(release);
if (!artifactSha256) return null;
const owner = await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
});
if (!(await isOfficialPublisher(ctx, owner))) return null;
const publisherId = owner?.handle?.trim();
if (!publisherId) return null;
const packageName = pkg.name.trim();
const id = pkg.normalizedName.trim();
const title = pkg.displayName.trim() || packageName;
const version = release.version.trim();
if (!packageName || !id || !title || !version) return null;
return {
type: "plugin",
id,
title,
version,
state: "available",
publisher: {
id: publisherId,
trust: "official",
},
install: {
candidates: [
{
sourceRef: CATALOG_FEED_SOURCE_REF,
package: packageName,
version,
integrity: `sha256:${artifactSha256}`,
},
],
},
};
}
async function listFamilyEntries(
ctx: CatalogQueryCtx,
family: (typeof CATALOG_FEED_FAMILIES)[number],
) {
const entries: CatalogFeedEntry[] = [];
let cursor: string | null = null;
while (true) {
const page = await ctx.db
.query("packages")
.withIndex("by_active_family_official_downloads", (q) =>
q.eq("softDeletedAt", undefined).eq("family", family).eq("isOfficial", true),
)
.order("desc")
.paginate({ cursor, numItems: CATALOG_FEED_PAGE_SIZE });
for (const pkg of page.page) {
const entry = await buildEntry(ctx, pkg);
if (entry) entries.push(entry);
if (entries.length > MAX_CATALOG_FEED_ENTRIES) {
throw new Error(`Catalog feed exceeds ${MAX_CATALOG_FEED_ENTRIES} entries`);
}
}
if (page.isDone) return entries;
cursor = page.continueCursor;
}
}
export const listOfficialEntries = internalQuery({
args: {
family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")),
},
handler: async (ctx, args) => await listFamilyEntries(ctx, args.family),
});
export const storePublication = internalMutation({
args: {
generatedAt: v.string(),
expiresAt: v.string(),
entries: v.array(catalogFeedEntryValidator),
},
handler: async (ctx, args) => {
const latest = await ctx.db
.query("catalogFeedPublications")
.withIndex("by_feed", (q) => q.eq("feedId", CATALOG_FEED_ID))
.unique();
const sequence = (latest?.sequence ?? 0) + 1;
const payload = serializeCatalogFeed({
schemaVersion: CATALOG_FEED_SCHEMA_VERSION,
id: CATALOG_FEED_ID,
generatedAt: args.generatedAt,
sequence,
expiresAt: args.expiresAt,
description: CATALOG_FEED_DESCRIPTION,
entries: args.entries,
});
const payloadSha256 = await sha256Hex(new TextEncoder().encode(payload));
const publishedAt = Date.now();
const publication = {
feedId: CATALOG_FEED_ID,
sequence,
generatedAt: args.generatedAt,
expiresAt: args.expiresAt,
payload,
payloadSha256,
publishedAt,
};
const publicationId = latest
? (await ctx.db.patch(latest._id, publication), latest._id)
: await ctx.db.insert("catalogFeedPublications", publication);
return {
publicationId,
feedId: CATALOG_FEED_ID,
sequence,
payloadSha256,
publishedAt,
entryCount: args.entries.length,
};
},
});
export const publish = internalAction({
args: {
expiresAt: v.string(),
},
handler: async (ctx, args): Promise<CatalogFeedPublicationResult> => {
const generatedAt = new Date().toISOString();
const familyEntries: CatalogFeedEntry[][] = await Promise.all(
CATALOG_FEED_FAMILIES.map(async (family) => {
const entries: CatalogFeedEntry[] = await ctx.runQuery(
internal.catalogFeed.listOfficialEntries,
{ family },
);
return entries;
}),
);
const entries = familyEntries.flat();
if (entries.length > MAX_CATALOG_FEED_ENTRIES) {
throw new Error(`Catalog feed exceeds ${MAX_CATALOG_FEED_ENTRIES} entries`);
}
const result: CatalogFeedPublicationResult = await ctx.runMutation(
internal.catalogFeed.storePublication,
{
generatedAt,
expiresAt: args.expiresAt,
entries: entries.sort((left, right) => left.id.localeCompare(right.id)),
},
);
return result;
},
});
export const getLatestPublication = internalQuery({
args: {},
handler: async (ctx) =>
await ctx.db
.query("catalogFeedPublications")
.withIndex("by_feed", (q) => q.eq("feedId", CATALOG_FEED_ID))
.unique(),
});
+7
View File
@@ -46,6 +46,7 @@ import {
starsPostRouterV1Http,
transfersGetRouterV1Http,
banAppealContextV1Http,
catalogFeedV1Http,
usersGetRouterV1Http,
usersListV1Http,
usersPostRouterV1Http,
@@ -130,6 +131,12 @@ http.route({
handler: listBundlePluginsV1Http,
});
http.route({
path: ApiRoutes.catalogFeed,
method: "GET",
handler: catalogFeedV1Http,
});
http.route({
pathPrefix: `${ApiRoutes.skills}/`,
method: "GET",
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import { catalogFeedV1Handler } from "./httpApiV1/catalogFeedV1";
type QueryCtx = {
runQuery: ReturnType<typeof vi.fn>;
};
const publication = {
feedId: "clawhub-official",
sequence: 4,
generatedAt: "2026-06-23T00:00:00.000Z",
expiresAt: "2026-06-30T00:00:00.000Z",
payload: '{"schemaVersion":1,"id":"clawhub-official","entries":[]}',
payloadSha256: "abc123",
publishedAt: Date.parse("2026-06-23T00:00:00.000Z"),
};
describe("catalogFeedV1Handler", () => {
let ctx: QueryCtx;
beforeEach(() => {
ctx = { runQuery: vi.fn().mockResolvedValue(publication) };
});
it("serves the exact published payload with edge cache validators", async () => {
const response = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed"),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe(publication.payload);
expect(response.headers.get("etag")).toBe('"sha256:abc123"');
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, {});
});
it("returns 304 for a matching validator", async () => {
const response = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed", {
headers: { "If-None-Match": '"sha256:abc123"' },
}),
);
expect(response.status).toBe(304);
expect(await response.text()).toBe("");
});
it("does not cache an unpublished feed", async () => {
ctx.runQuery.mockResolvedValue(null);
const response = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed"),
);
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
});
});
+3
View File
@@ -1,4 +1,5 @@
import { httpAction } from "./functions";
import { catalogFeedV1Handler } from "./httpApiV1/catalogFeedV1";
import { contentRightsV1Handler } from "./httpApiV1/contentRightsV1";
import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1";
import {
@@ -56,6 +57,7 @@ export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
export const verifyDocsSessionV1Http = httpAction(verifyDocsSessionV1Handler);
export const createPublisherV1Http = httpAction(createPublisherV1Handler);
export const contentRightsV1Http = httpAction(contentRightsV1Handler);
export const catalogFeedV1Http = httpAction(catalogFeedV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
@@ -97,6 +99,7 @@ export const __handlers = {
verifyDocsSessionV1Handler,
createPublisherV1Handler,
contentRightsV1Handler,
catalogFeedV1Handler,
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
+47
View File
@@ -0,0 +1,47 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
function matchesEtag(request: Request, etag: string) {
const header = request.headers.get("if-none-match");
if (!header) return false;
return header
.split(",")
.map((value) => value.trim())
.some((value) => value === "*" || value === etag);
}
export async function catalogFeedV1Handler(ctx: ActionCtx, request: Request) {
const publication = await ctx.runQuery(internal.catalogFeed.getLatestPublication, {});
if (!publication) {
return new Response("Catalog feed is not published", {
status: 503,
headers: mergeHeaders(
{
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
corsHeaders(),
),
});
}
const etag = `"sha256:${publication.payloadSha256}"`;
const headers = mergeHeaders(
{
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
"Surrogate-Control": "max-age=300, stale-while-revalidate=86400",
ETag: etag,
"Last-Modified": new Date(publication.publishedAt).toUTCString(),
"X-Catalog-Feed-Sequence": String(publication.sequence),
"X-Content-SHA256": publication.payloadSha256,
"X-Content-Type-Options": "nosniff",
Vary: "Accept-Encoding",
},
corsHeaders(),
);
if (matchesEtag(request, etag)) return new Response(null, { status: 304, headers });
return new Response(publication.payload, { status: 200, headers });
}
+1
View File
@@ -177,6 +177,7 @@ export const RETENTION_POLICIES = {
packageAppeals: permanent("Package moderation appeals and audit history."),
packageModerationEventLogs: permanent("Package moderation event audit log."),
officialPluginMigrations: permanent("Official plugin migration state."),
catalogFeedPublications: permanent("Current published hosted catalog feed snapshot."),
stars: permanent("User star records."),
auditLogs: permanent("Audit logs are durable compliance/security history."),
publisherAbuseScoreRuns: permanent("Abuse scoring run history."),
+11
View File
@@ -2433,6 +2433,16 @@ const officialPluginMigrations = defineTable({
.index("by_phase_updatedAt", ["phase", "updatedAt"])
.index("by_updatedAt", ["updatedAt"]);
const catalogFeedPublications = defineTable({
feedId: v.string(),
sequence: v.number(),
generatedAt: v.string(),
expiresAt: v.string(),
payload: v.string(),
payloadSha256: v.string(),
publishedAt: v.number(),
}).index("by_feed", ["feedId"]);
const stars = defineTable({
skillId: v.id("skills"),
userId: v.id("users"),
@@ -2900,6 +2910,7 @@ export default defineSchema({
packageAppeals,
packageModerationEventLogs,
officialPluginMigrations,
catalogFeedPublications,
stars,
auditLogs,
publisherAbuseScoreRuns,
+65
View File
@@ -0,0 +1,65 @@
import { type inferred } from "arktype";
export declare const CatalogFeedStateSchema: import("arktype/internal/variants/string.ts").StringType<"available" | "recommended" | "disabled" | "blocked" | "deprecated", {}>;
export type CatalogFeedState = (typeof CatalogFeedStateSchema)[inferred];
export declare const CatalogFeedPublisherTrustSchema: import("arktype/internal/variants/string.ts").StringType<"official" | "community", {}>;
export type CatalogFeedPublisherTrust = (typeof CatalogFeedPublisherTrustSchema)[inferred];
export declare const CatalogFeedInstallCandidateSchema: import("arktype/internal/variants/object.ts").ObjectType<{
sourceRef: string;
package: string;
version: string;
integrity: string;
}, {}>;
export type CatalogFeedInstallCandidate = (typeof CatalogFeedInstallCandidateSchema)[inferred];
export declare const CatalogFeedEntrySchema: 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 CatalogFeedEntry = (typeof CatalogFeedEntrySchema)[inferred];
export declare const CatalogFeedSchema: import("arktype/internal/variants/object.ts").ObjectType<{
schemaVersion: number;
id: string;
generatedAt: string;
sequence: number;
expiresAt: string;
entries: {
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;
}[];
};
}[];
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 function parseCatalogFeed(value: unknown): CatalogFeed;
export declare function serializeCatalogFeed(feed: CatalogFeed): string;
+63
View File
@@ -0,0 +1,63 @@
import { type } from "arktype";
export const CatalogFeedStateSchema = type('"available"|"recommended"|"disabled"|"blocked"|"deprecated"');
export const CatalogFeedPublisherTrustSchema = type('"official"|"community"');
export const CatalogFeedInstallCandidateSchema = type({
"+": "reject",
sourceRef: "string",
package: "string",
version: "string",
integrity: "string",
});
export const CatalogFeedEntrySchema = type({
"+": "reject",
type: '"plugin"',
id: "string",
title: "string",
version: "string",
state: CatalogFeedStateSchema,
publisher: {
"+": "reject",
id: "string",
trust: CatalogFeedPublisherTrustSchema,
},
install: {
"+": "reject",
candidates: CatalogFeedInstallCandidateSchema.array(),
},
});
export const CatalogFeedSchema = type({
"+": "reject",
schemaVersion: "number",
id: "string",
generatedAt: "string",
sequence: "number",
expiresAt: "string",
description: "string?",
entries: CatalogFeedEntrySchema.array(),
});
export const CATALOG_FEED_SCHEMA_VERSION = 1;
export const CATALOG_FEED_ID = "clawhub-official";
export const CATALOG_FEED_SOURCE_REF = "public-clawhub";
export function parseCatalogFeed(value) {
const feed = CatalogFeedSchema.assert(value);
if (feed.schemaVersion !== CATALOG_FEED_SCHEMA_VERSION) {
throw new Error(`Unsupported catalog feed schema version: ${feed.schemaVersion}`);
}
if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
throw new Error("Catalog feed sequence must be a non-negative integer");
}
if (!Number.isFinite(Date.parse(feed.generatedAt)) ||
!Number.isFinite(Date.parse(feed.expiresAt))) {
throw new Error("Catalog feed timestamps must be valid ISO dates");
}
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
throw new Error("Catalog feed expiresAt must be after generatedAt");
}
return feed;
}
export function serializeCatalogFeed(feed) {
const parsed = parseCatalogFeed(feed);
const entries = [...parsed.entries].sort((left, right) => left.id.localeCompare(right.id));
return JSON.stringify({ ...parsed, entries });
}
//# sourceMappingURL=catalogFeed.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"}
+1
View File
@@ -1,5 +1,6 @@
export type { ArkValidator } from "./ark.js";
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
+1
View File
@@ -1,4 +1,5 @@
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
+1
View File
@@ -22,6 +22,7 @@ export declare const ApiRoutes: {
readonly packages: "/api/v1/packages";
readonly codePlugins: "/api/v1/code-plugins";
readonly bundlePlugins: "/api/v1/bundle-plugins";
readonly catalogFeed: "/api/v1/feeds/plugins";
readonly stars: "/api/v1/stars";
readonly transfers: "/api/v1/transfers";
readonly publishers: "/api/v1/publishers";
+1
View File
@@ -22,6 +22,7 @@ export const ApiRoutes = {
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
catalogFeed: "/api/v1/feeds/plugins",
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,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,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"}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import {
CATALOG_FEED_ID,
CATALOG_FEED_SCHEMA_VERSION,
CATALOG_FEED_SOURCE_REF,
parseCatalogFeed,
serializeCatalogFeed,
} from "./catalogFeed.js";
function makeFeed(overrides: Record<string, unknown> = {}) {
return {
schemaVersion: CATALOG_FEED_SCHEMA_VERSION,
id: CATALOG_FEED_ID,
generatedAt: "2026-06-23T00:00:00.000Z",
sequence: 1,
expiresAt: "2026-06-30T00:00:00.000Z",
entries: [
{
type: "plugin",
id: "zeta",
title: "Zeta",
version: "1.0.0",
state: "available",
publisher: { id: "openclaw", trust: "official" },
install: {
candidates: [
{
sourceRef: CATALOG_FEED_SOURCE_REF,
package: "@openclaw/zeta",
version: "1.0.0",
integrity: "sha256:abc",
},
],
},
},
{
type: "plugin",
id: "alpha",
title: "Alpha",
version: "1.0.0",
state: "available",
publisher: { id: "openclaw", trust: "official" },
install: {
candidates: [
{
sourceRef: CATALOG_FEED_SOURCE_REF,
package: "@openclaw/alpha",
version: "1.0.0",
integrity: "sha256:def",
},
],
},
},
],
...overrides,
};
}
describe("catalog feed schema", () => {
it("sorts entries by stable id before serializing", () => {
const serialized = serializeCatalogFeed(makeFeed() as never);
expect(serialized.indexOf('"id":"alpha"')).toBeLessThan(serialized.indexOf('"id":"zeta"'));
});
it("rejects unsupported versions and expired feeds", () => {
expect(() => parseCatalogFeed(makeFeed({ schemaVersion: 2 }))).toThrow(
"Unsupported catalog feed schema version",
);
expect(() => parseCatalogFeed(makeFeed({ expiresAt: "2026-06-22T00:00:00.000Z" }))).toThrow(
"expiresAt must be after generatedAt",
);
});
it("rejects malformed install candidates", () => {
expect(() =>
parseCatalogFeed(
makeFeed({
entries: [
{
...makeFeed().entries[0],
install: { candidates: [{ sourceRef: CATALOG_FEED_SOURCE_REF }] },
},
],
}),
),
).toThrow();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { type inferred, type } from "arktype";
export const CatalogFeedStateSchema = type(
'"available"|"recommended"|"disabled"|"blocked"|"deprecated"',
);
export type CatalogFeedState = (typeof CatalogFeedStateSchema)[inferred];
export const CatalogFeedPublisherTrustSchema = type('"official"|"community"');
export type CatalogFeedPublisherTrust = (typeof CatalogFeedPublisherTrustSchema)[inferred];
export const CatalogFeedInstallCandidateSchema = type({
"+": "reject",
sourceRef: "string",
package: "string",
version: "string",
integrity: "string",
});
export type CatalogFeedInstallCandidate = (typeof CatalogFeedInstallCandidateSchema)[inferred];
export const CatalogFeedEntrySchema = type({
"+": "reject",
type: '"plugin"',
id: "string",
title: "string",
version: "string",
state: CatalogFeedStateSchema,
publisher: {
"+": "reject",
id: "string",
trust: CatalogFeedPublisherTrustSchema,
},
install: {
"+": "reject",
candidates: CatalogFeedInstallCandidateSchema.array(),
},
});
export type CatalogFeedEntry = (typeof CatalogFeedEntrySchema)[inferred];
export const CatalogFeedSchema = type({
"+": "reject",
schemaVersion: "number",
id: "string",
generatedAt: "string",
sequence: "number",
expiresAt: "string",
description: "string?",
entries: CatalogFeedEntrySchema.array(),
});
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 function parseCatalogFeed(value: unknown): CatalogFeed {
const feed = CatalogFeedSchema.assert(value);
if (feed.schemaVersion !== CATALOG_FEED_SCHEMA_VERSION) {
throw new Error(`Unsupported catalog feed schema version: ${feed.schemaVersion}`);
}
if (feed.sequence < 0 || !Number.isSafeInteger(feed.sequence)) {
throw new Error("Catalog feed sequence must be a non-negative integer");
}
if (
!Number.isFinite(Date.parse(feed.generatedAt)) ||
!Number.isFinite(Date.parse(feed.expiresAt))
) {
throw new Error("Catalog feed timestamps must be valid ISO dates");
}
if (Date.parse(feed.expiresAt) <= Date.parse(feed.generatedAt)) {
throw new Error("Catalog feed expiresAt must be after generatedAt");
}
return feed;
}
export function serializeCatalogFeed(feed: CatalogFeed): string {
const parsed = parseCatalogFeed(feed);
const entries = [...parsed.entries].sort((left, right) => left.id.localeCompare(right.id));
return JSON.stringify({ ...parsed, entries });
}
+1
View File
@@ -1,5 +1,6 @@
export type { ArkValidator } from "./ark.js";
export { formatArkErrors, parseArk } from "./ark.js";
export * from "./catalogFeed.js";
export * from "./catalogMetadata.js";
export * from "./docsLinks.js";
export * from "./license.js";
+1
View File
@@ -23,6 +23,7 @@ export const ApiRoutes = {
packages: "/api/v1/packages",
codePlugins: "/api/v1/code-plugins",
bundlePlugins: "/api/v1/bundle-plugins",
catalogFeed: "/api/v1/feeds/plugins",
stars: "/api/v1/stars",
transfers: "/api/v1/transfers",
publishers: "/api/v1/publishers",
+14
View File
@@ -169,6 +169,20 @@ The CLI can discover the API base from:
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:
```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
`/feeds/plugins` with public edge-cache validators. Attach
`registry.openclaw.ai` to the same Vercel project before configuring OpenClaw's
default feed URL.
## 5) Post-deploy checks
Run the contract verifier and smoke tests against production after deploy:
+68
View File
@@ -0,0 +1,68 @@
---
summary: "ClawHub publication contract for the OpenClaw hosted plugin catalog feed."
read_when:
- Publishing the OpenClaw hosted plugin catalog feed
- Changing feed entries, cache headers, or publication workflow
- Wiring registry.openclaw.ai to ClawHub
---
# 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.
## Contract
- Feed id: `clawhub-official`
- Schema version: `1`
- Initial scope: `code-plugin` and `bundle-plugin` packages only
- Source profile: `public-clawhub`
- Entry identity: normalized ClawHub package name
- Install coordinate: package name plus exact release version
- Integrity: `sha256:<artifact sha256>`
- Publisher trust: `official`, derived from ClawHub's official publisher state
- Initial entry state: `available`
- Required feed metadata: `generatedAt`, monotonic `sequence`, and `expiresAt`
The producer excludes soft-deleted packages, inactive releases, releases without
an artifact digest, and releases blocked by ClawHub security or moderation
state. The feed contains no registry URLs, credentials, source tokens, or
bootstrap trust keys.
The feed intentionally emits RFC 19's canonical entry shape rather than
OpenClaw's current legacy bundled-catalog entries. The staged OpenClaw hosted
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.
## 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.
The `Publish Hosted Catalog Feed` workflow refreshes the snapshot every six
hours and can be run manually. It requires the existing `Production` environment
`CONVEX_DEPLOY_KEY`. The workflow currently publishes an unsigned feed; signed
envelopes require a separate production key-management decision and must not be
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:
- `ETag: "sha256:<payload hash>"`
- `Last-Modified`
- `Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400`
- `Surrogate-Control: max-age=300, stale-while-revalidate=86400`
- `304 Not Modified` for matching `If-None-Match`
`vercel.json` exposes `/feeds/plugins` as an edge-friendly rewrite to the
Convex endpoint. The `registry.openclaw.ai` custom domain must point at the
same Vercel project before the public RFC URL is enabled.
Do not make the feed request-time dynamic. Refresh the stored publication first,
then let Vercel or the configured CDN cache the immutable response by ETag.
+4
View File
@@ -50,6 +50,10 @@
{
"source": "/api/:path*",
"destination": "https://wry-manatee-359.convex.site/api/:path*"
},
{
"source": "/feeds/plugins",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins"
}
],
"images": {