mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
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:
@@ -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
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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."),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Vendored
+63
@@ -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
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"catalogFeed.js","sourceRoot":"","sources":["../src/catalogFeed.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+1
@@ -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";
|
||||
|
||||
Vendored
+1
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,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"}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user