fix(catalog): version and harden hosted plugin feed (#2806)

This commit is contained in:
Vincent Koc
2026-06-23 16:11:40 +08:00
committed by GitHub
parent 2cf2636ab8
commit 94f2f532c3
13 changed files with 194 additions and 20 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
needs: validate-ref
environment:
name: Production
url: https://registry.openclaw.ai/feeds/plugins
url: https://registry.openclaw.ai/v1/feeds/plugins
env:
EXPIRES_IN_DAYS: ${{ inputs.expires_in_days || '7' }}
steps:
+31
View File
@@ -50,6 +50,37 @@ describe("catalogFeedV1Handler", () => {
expect(await response.text()).toBe("");
});
it("accepts weak etags and last-modified validators", async () => {
const weakEtagResponse = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed", {
headers: { "If-None-Match": 'W/"sha256:abc123"' },
}),
);
expect(weakEtagResponse.status).toBe(304);
const lastModifiedResponse = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed", {
headers: { "If-Modified-Since": "Tue, 23 Jun 2026 00:00:00 GMT" },
}),
);
expect(lastModifiedResponse.status).toBe(304);
});
it("gives etag precedence over last-modified", async () => {
const response = await catalogFeedV1Handler(
ctx as never,
new Request("https://clawhub.ai/feed", {
headers: {
"If-None-Match": '"sha256:different"',
"If-Modified-Since": "Tue, 23 Jun 2026 00:00:00 GMT",
},
}),
);
expect(response.status).toBe(200);
});
it("does not cache an unpublished feed", async () => {
ctx.runQuery.mockResolvedValue(null);
const response = await catalogFeedV1Handler(
+19 -2
View File
@@ -8,7 +8,19 @@ function matchesEtag(request: Request, etag: string) {
return header
.split(",")
.map((value) => value.trim())
.some((value) => value === "*" || value === etag);
.some((value) => {
if (value === "*") return true;
const normalized = value.startsWith("W/") ? value.slice(2) : value;
return normalized === etag;
});
}
function matchesLastModified(request: Request, publishedAt: number) {
const header = request.headers.get("if-modified-since");
if (!header) return false;
const since = Date.parse(header);
if (!Number.isFinite(since)) return false;
return Math.floor(publishedAt / 1000) * 1000 <= since;
}
export async function catalogFeedV1Handler(ctx: ActionCtx, request: Request) {
@@ -42,6 +54,11 @@ export async function catalogFeedV1Handler(ctx: ActionCtx, request: Request) {
corsHeaders(),
);
if (matchesEtag(request, etag)) return new Response(null, { status: 304, headers });
if (
matchesEtag(request, etag) ||
(!request.headers.has("if-none-match") && matchesLastModified(request, publication.publishedAt))
) {
return new Response(null, { status: 304, headers });
}
return new Response(publication.payload, { status: 200, headers });
}
+34 -2
View File
@@ -57,7 +57,39 @@ export function parseCatalogFeed(value) {
}
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 });
const entries = [...parsed.entries]
.sort((left, right) => left.id.localeCompare(right.id))
.map((entry) => ({
type: entry.type,
id: entry.id,
title: entry.title,
version: entry.version,
state: entry.state,
publisher: {
id: entry.publisher.id,
trust: entry.publisher.trust,
},
install: {
candidates: [...entry.install.candidates]
.sort((left, right) => [left.sourceRef, left.package, left.version, left.integrity]
.join("\u0000")
.localeCompare([right.sourceRef, right.package, right.version, right.integrity].join("\u0000")))
.map((candidate) => ({
sourceRef: candidate.sourceRef,
package: candidate.package,
version: candidate.version,
integrity: candidate.integrity,
})),
},
}));
return JSON.stringify({
schemaVersion: parsed.schemaVersion,
id: parsed.id,
generatedAt: parsed.generatedAt,
sequence: parsed.sequence,
expiresAt: parsed.expiresAt,
...(parsed.description === undefined ? {} : { description: parsed.description }),
entries,
});
}
//# sourceMappingURL=catalogFeed.js.map
+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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"}
+34 -4
View File
@@ -5,9 +5,10 @@ import {
CATALOG_FEED_SOURCE_REF,
parseCatalogFeed,
serializeCatalogFeed,
type CatalogFeed,
} from "./catalogFeed.js";
function makeFeed(overrides: Record<string, unknown> = {}) {
function makeFeed(overrides: Partial<CatalogFeed> = {}): CatalogFeed {
return {
schemaVersion: CATALOG_FEED_SCHEMA_VERSION,
id: CATALOG_FEED_ID,
@@ -58,12 +59,41 @@ function makeFeed(overrides: Record<string, unknown> = {}) {
describe("catalog feed schema", () => {
it("sorts entries by stable id before serializing", () => {
const serialized = serializeCatalogFeed(makeFeed() as never);
const serialized = serializeCatalogFeed(makeFeed());
expect(serialized.indexOf('"id":"alpha"')).toBeLessThan(serialized.indexOf('"id":"zeta"'));
});
it("serializes equivalent objects to identical canonical bytes", () => {
const feed = makeFeed();
const reordered: CatalogFeed = {
entries: feed.entries.map((entry) => ({
install: {
candidates: entry.install.candidates.map((candidate) => ({
integrity: candidate.integrity,
version: candidate.version,
package: candidate.package,
sourceRef: candidate.sourceRef,
})),
},
publisher: entry.publisher,
state: entry.state,
version: entry.version,
title: entry.title,
id: entry.id,
type: entry.type,
})),
expiresAt: feed.expiresAt,
sequence: feed.sequence,
generatedAt: feed.generatedAt,
id: feed.id,
schemaVersion: feed.schemaVersion,
};
expect(serializeCatalogFeed(feed)).toBe(serializeCatalogFeed(reordered));
});
it("rejects unsupported versions and expired feeds", () => {
expect(() => parseCatalogFeed(makeFeed({ schemaVersion: 2 }))).toThrow(
expect(() => parseCatalogFeed(makeFeed({ schemaVersion: 2 } as never))).toThrow(
"Unsupported catalog feed schema version",
);
expect(() => parseCatalogFeed(makeFeed({ expiresAt: "2026-06-22T00:00:00.000Z" }))).toThrow(
@@ -81,7 +111,7 @@ describe("catalog feed schema", () => {
install: { candidates: [{ sourceRef: CATALOG_FEED_SOURCE_REF }] },
},
],
}),
} as never),
),
).toThrow();
});
+38 -2
View File
@@ -74,6 +74,42 @@ export function parseCatalogFeed(value: unknown): CatalogFeed {
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 });
const entries = [...parsed.entries]
.sort((left, right) => left.id.localeCompare(right.id))
.map((entry) => ({
type: entry.type,
id: entry.id,
title: entry.title,
version: entry.version,
state: entry.state,
publisher: {
id: entry.publisher.id,
trust: entry.publisher.trust,
},
install: {
candidates: [...entry.install.candidates]
.sort((left, right) =>
[left.sourceRef, left.package, left.version, left.integrity]
.join("\u0000")
.localeCompare(
[right.sourceRef, right.package, right.version, right.integrity].join("\u0000"),
),
)
.map((candidate) => ({
sourceRef: candidate.sourceRef,
package: candidate.package,
version: candidate.version,
integrity: candidate.integrity,
})),
},
}));
return JSON.stringify({
schemaVersion: parsed.schemaVersion,
id: parsed.id,
generatedAt: parsed.generatedAt,
sequence: parsed.sequence,
expiresAt: parsed.expiresAt,
...(parsed.description === undefined ? {} : { description: parsed.description }),
entries,
});
}
@@ -0,0 +1,8 @@
{
"type": "openclaw.registry",
"schemaVersion": 1,
"registry": "https://registry.openclaw.ai",
"feeds": {
"plugins": "https://registry.openclaw.ai/v1/feeds/plugins"
}
}
+4
View File
@@ -0,0 +1,4 @@
Contact: mailto:security@openclaw.ai
Expires: 2027-06-23T00:00:00.000Z
Preferred-Languages: en
Canonical: https://clawhub.ai/.well-known/security.txt
+3 -2
View File
@@ -1,3 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Disallow: /api/
Disallow: /admin/
Allow: /v1/feeds/plugins
+2 -1
View File
@@ -179,7 +179,8 @@ 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
`/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.
+13 -4
View File
@@ -58,11 +58,20 @@ unchanged and provides:
- `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`
- `304 Not Modified` for matching `If-None-Match` or `If-Modified-Since`
`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.
`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.
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/clawhub.json` remains the ClawHub API discovery document.
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.
+6 -1
View File
@@ -21,6 +21,11 @@
}
],
"redirects": [
{
"source": "/feeds/plugins",
"destination": "/v1/feeds/plugins",
"statusCode": 308
},
{
"source": "/",
"has": [
@@ -52,7 +57,7 @@
"destination": "https://wry-manatee-359.convex.site/api/:path*"
},
{
"source": "/feeds/plugins",
"source": "/v1/feeds/plugins",
"destination": "https://wry-manatee-359.convex.site/api/v1/feeds/plugins"
}
],