fix: stream legacy skill downloads (#3451)

* fix: stream legacy skill downloads

* fix: stream zip entries in bounded chunks

* fix: stream large archives through api owner

* fix: normalize streamed archive chunks

* fix: authenticate archive streaming handoff

* fix: authenticate archive manifest requests

* test: bound archive determinism fixture

* fix: align archive oidc trust with vercel targets

* fix: harden archive runtime boundaries

* test: isolate archive proxy credentials

* test: exercise streamed manifest size cap
This commit is contained in:
Patrick Erichsen
2026-08-12 12:19:37 -07:00
committed by GitHub
parent faab45bace
commit 60b02c09f9
16 changed files with 1963 additions and 56 deletions
+1
View File
@@ -41,6 +41,7 @@
"fflate": "0.8.3",
"h3": "2.0.1-rc.25",
"ignore": "7.0.6",
"jose": "6.2.3",
"lucide-react": "1.28.0",
"mermaid": "^11.16.1",
"mime": "4.1.0",
+430 -13
View File
@@ -1,7 +1,20 @@
import type { RateLimitArgs, RateLimitReturns } from "@convex-dev/rate-limiter";
import { unzipSync } from "fflate";
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ActionCtx } from "./_generated/server";
import { __test, downloadZipHandler } from "./downloads";
import { __test, downloadZipHandler, recordArchiveDownloadMetricHandler } from "./downloads";
import {
ARCHIVE_MANIFEST_AUDIENCE,
ARCHIVE_MANIFEST_CONTENT_TYPE,
ARCHIVE_MANIFEST_JWS_TYPE,
ARCHIVE_METRIC_AUDIENCE,
ARCHIVE_METRIC_JWS_TYPE,
type ArchiveMetricPayload,
signArchivePayload,
type SkillArchiveManifest,
verifyArchivePayloadWithLocalJwks,
} from "./lib/archiveManifest";
function isRateLimitArgs(args: unknown): args is RateLimitArgs {
if (!args || typeof args !== "object") return false;
@@ -35,8 +48,30 @@ function stubZipResponse() {
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function streamingBlob(text: string) {
const bytes = new TextEncoder().encode(text);
return {
stream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
}),
} as Blob;
}
describe("downloads helpers", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
@@ -79,7 +114,6 @@ describe("downloads helpers", () => {
it("schedules zip download stats outside the response path", async () => {
vi.stubEnv("TRUST_FORWARDED_IPS", "true");
stubZipResponse();
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
@@ -111,23 +145,28 @@ describe("downloads helpers", () => {
return { mutation, args };
});
const runAfter = vi.fn();
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
const storageGet = vi.fn().mockResolvedValue(streamingBlob("hello"));
const response = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter },
storage: { get: storageGet },
storage: {
get: storageGet,
getMetadata: vi.fn().mockResolvedValue({}),
},
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo", {
new Request("https://preview-branch-123.convex.site/api/v1/download?slug=demo", {
headers: { "cf-connecting-ip": "1.2.3.4" },
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/zip");
const archive = new Uint8Array(await response.arrayBuffer());
expect(storageGet).toHaveBeenCalledWith("_storage:1");
expect(new TextDecoder().decode(unzipSync(archive)["SKILL.md"])).toBe("hello");
const recordCalls = runAfter.mock.calls.filter(([, , args]) => {
if (!args || typeof args !== "object") return false;
@@ -152,6 +191,373 @@ describe("downloads helpers", () => {
});
});
it("returns a bounded archive manifest to the Nitro streaming owner", async () => {
vi.stubEnv("CLAWHUB_PREVIEW", "1");
vi.stubEnv("TRUST_FORWARDED_IPS", "true");
vi.spyOn(Date, "now").mockReturnValue(10_000);
const keyPair = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keyPair.privateKey);
const publicKey = await exportJWK(keyPair.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
vi.stubEnv("JWT_PRIVATE_KEY", privateKey);
vi.stubEnv("JWKS", jwks);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: {
_id: "skills:1",
ownerUserId: "users:1",
slug: "demo",
tags: {},
latestVersionId: "skillVersions:1",
},
moderationInfo: null,
};
}
if ("versionId" in args) {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0+build",
createdAt: 3,
files: [
{ path: "SKILL.md", storageId: "_storage:1" },
{ path: "missing.txt", storageId: "_storage:missing" },
],
softDeletedAt: undefined,
};
}
return null;
});
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return null;
});
const runAfter = vi.fn();
const storageGet = vi.fn();
const storageGetUrl = vi.fn(async (storageId: string) =>
storageId === "_storage:1"
? "https://preview-branch-123.convex.cloud/api/storage/storage-1"
: null,
);
const response = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter },
storage: {
get: storageGet,
getUrl: storageGetUrl,
getMetadata: vi.fn(),
},
} as unknown as ActionCtx,
new Request("https://preview-branch-123.convex.site/api/v1/download?slug=demo", {
headers: {
"cf-connecting-ip": "1.2.3.4",
"x-clawhub-archive-manifest": "v1",
"x-clawhub-vercel-oidc-token": "vercel-oidc",
},
}),
{ verifyArchiveRequester: vi.fn(async () => undefined) },
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe(ARCHIVE_MANIFEST_CONTENT_TYPE);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
const manifest = (await verifyArchivePayloadWithLocalJwks(
await response.text(),
ARCHIVE_MANIFEST_JWS_TYPE,
jwks,
)) as SkillArchiveManifest;
expect(manifest).toEqual({
schema: "clawhub.skill-archive-manifest.v1",
issuer: "https://preview-branch-123.convex.site",
audience: ARCHIVE_MANIFEST_AUDIENCE,
issuedAt: 10_000,
expiresAt: 40_000,
filename: "demo-1.0.0+build.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0+build",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
],
metricToken: expect.any(String),
});
const metricPayload = (await verifyArchivePayloadWithLocalJwks(
manifest.metricToken!,
ARCHIVE_METRIC_JWS_TYPE,
jwks,
)) as ArchiveMetricPayload;
expect(metricPayload).toMatchObject({
schema: "clawhub.archive-download-metric.v1",
issuer: "https://preview-branch-123.convex.site",
audience: ARCHIVE_METRIC_AUDIENCE,
issuedAt: 10_000,
expiresAt: 40_000,
metric: {
target: { kind: "skill", id: "skills:1" },
identityKind: "ip",
identityHash: expect.any(String),
dayStart: 0,
occurredAt: 10_000,
},
});
expect(storageGet).not.toHaveBeenCalled();
expect(storageGetUrl).toHaveBeenCalledTimes(2);
expect(runAfter).not.toHaveBeenCalled();
});
it("rejects a direct manifest request without the Nitro Vercel identity", async () => {
vi.stubEnv("CLAWHUB_PREVIEW", "1");
const runQuery = vi.fn();
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return null;
});
const verifyArchiveRequester = vi.fn(async () => {
throw new Error("invalid Vercel identity");
});
const response = await downloadZipHandler(
{ runQuery, runMutation } as unknown as ActionCtx,
new Request("https://preview-branch-123.convex.site/api/v1/download?slug=demo", {
headers: {
"x-clawhub-archive-manifest": "v1",
"x-clawhub-vercel-oidc-token": "client-forgery",
},
}),
{ verifyArchiveRequester },
);
expect(response.status).toBe(401);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(verifyArchiveRequester).toHaveBeenCalledWith("client-forgery", "preview");
expect(runQuery).not.toHaveBeenCalled();
});
it("records only a valid, unexpired archive metric capability", async () => {
const keyPair = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keyPair.privateKey);
const publicKey = await exportJWK(keyPair.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
vi.stubEnv("JWKS", jwks);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const payload: ArchiveMetricPayload = {
schema: "clawhub.archive-download-metric.v1",
issuer: "https://example.com",
audience: ARCHIVE_METRIC_AUDIENCE,
issuedAt: 1_000,
expiresAt: 31_000,
metric: {
target: { kind: "skill", id: "skills:1" },
identityKind: "ip",
identityHash: "identity-hash",
dayStart: 0,
occurredAt: 1_000,
},
};
const token = await signArchivePayload(payload, ARCHIVE_METRIC_JWS_TYPE, privateKey);
const runAfter = vi.fn();
const ctx = { scheduler: { runAfter } } as unknown as ActionCtx;
const response = await recordArchiveDownloadMetricHandler(
ctx,
new Request("https://example.com/api/internal/archive-download-metric", {
method: "POST",
body: token,
}),
);
expect(response.status).toBe(204);
expect(runAfter).toHaveBeenCalledWith(expect.any(Number), expect.anything(), payload.metric);
const [header, body, signature] = token.split(".");
const modifiedBody = `${body!.slice(0, -1)}${body!.endsWith("A") ? "B" : "A"}`;
const modifiedToken = `${header}.${modifiedBody}.${signature}`;
const modifiedResponse = await recordArchiveDownloadMetricHandler(
ctx,
new Request("https://example.com/api/internal/archive-download-metric", {
method: "POST",
body: modifiedToken,
}),
);
expect(modifiedResponse.status).toBe(401);
const expiredToken = await signArchivePayload(
{ ...payload, expiresAt: 1_500 },
ARCHIVE_METRIC_JWS_TYPE,
privateKey,
);
const expiredResponse = await recordArchiveDownloadMetricHandler(
ctx,
new Request("https://example.com/api/internal/archive-download-metric", {
method: "POST",
body: expiredToken,
}),
);
expect(expiredResponse.status).toBe(401);
expect(runAfter).toHaveBeenCalledTimes(1);
});
it("streams stored file chunks, stays deterministic, and skips a Blob that vanishes", async () => {
const firstChunk = new Uint8Array(64 * 1024).fill(0x61);
const secondChunk = new TextEncoder().encode("streamed body\n");
const releaseSecondChunk = deferred<void>();
const arrayBuffer = vi.fn(() => Promise.reject(new Error("whole Blob read")));
const stream = vi.fn(
() =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(firstChunk);
},
async pull(controller) {
await releaseSecondChunk.promise;
controller.enqueue(secondChunk);
controller.close();
},
}),
);
const storageGetMetadata = vi.fn().mockResolvedValue({});
const storageGet = vi.fn(async (storageId: string) => {
if (storageId === "_storage:skill") {
return { arrayBuffer, stream } as unknown as Blob;
}
if (storageId === "_storage:notes") {
return {
stream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("supporting notes\n"));
controller.close();
},
}),
} as Blob;
}
return null;
});
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: {
_id: "skills:1",
ownerUserId: "users:1",
slug: "demo",
tags: {},
latestVersionId: "skillVersions:1",
},
moderationInfo: null,
};
}
if ("versionId" in args) {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 3,
files: [
{ path: "a.txt", storageId: "_storage:skill" },
{ path: "b.txt", storageId: "_storage:notes" },
{ path: "missing.txt", storageId: "_storage:missing" },
],
softDeletedAt: undefined,
};
}
return null;
});
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return null;
});
const response = await Promise.race([
downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: { get: storageGet, getMetadata: storageGetMetadata },
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo"),
),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("download handler read archive bodies before responding")),
1_000,
);
}),
]);
expect(response.status).toBe(200);
expect(storageGetMetadata).not.toHaveBeenCalled();
expect(storageGet).not.toHaveBeenCalled();
const reader = response.body!.getReader();
const firstArchiveChunk = await reader.read();
expect(firstArchiveChunk.done).toBe(false);
expect(stream).toHaveBeenCalledTimes(1);
expect(arrayBuffer).not.toHaveBeenCalled();
releaseSecondChunk.resolve();
const archiveChunks = [firstArchiveChunk.value!];
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
archiveChunks.push(chunk.value);
}
const responseBytes = Uint8Array.from(archiveChunks.flatMap((chunk) => [...chunk]));
const unzipped = unzipSync(responseBytes);
expect(Object.keys(unzipped).sort()).toEqual(["_meta.json", "a.txt", "b.txt"]);
expect(unzipped["a.txt"]).toEqual(Uint8Array.from([...firstChunk, ...secondChunk]));
expect(new TextDecoder().decode(unzipped["b.txt"])).toBe("supporting notes\n");
expect(storageGet).toHaveBeenCalledWith("_storage:missing");
const repeatResponse = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(async (storageId: string) => {
if (storageId === "_storage:skill") {
return {
stream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(firstChunk);
controller.enqueue(secondChunk);
controller.close();
},
}),
} as Blob;
}
if (storageId === "_storage:notes") {
return {
stream: () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("supporting notes\n"));
controller.close();
},
}),
} as Blob;
}
return null;
}),
getMetadata: storageGetMetadata,
},
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo"),
);
expect(new Uint8Array(await repeatResponse.arrayBuffer())).toEqual(responseBytes);
});
it("returns 410 for an explicitly requested revoked version", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
@@ -195,7 +601,7 @@ describe("downloads helpers", () => {
runQuery,
runMutation,
scheduler: { runAfter },
storage: { get: storageGet },
storage: { get: storageGet, getMetadata: vi.fn().mockResolvedValue({}) },
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo&version=1.0.0"),
);
@@ -241,7 +647,10 @@ describe("downloads helpers", () => {
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: { get: vi.fn().mockResolvedValue(new Blob(["hello"])) },
storage: {
get: vi.fn().mockResolvedValue(streamingBlob("hello")),
getMetadata: vi.fn().mockResolvedValue({}),
},
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo&ownerHandle=clawkit"),
);
@@ -302,7 +711,7 @@ describe("downloads helpers", () => {
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: { get: storageGet },
storage: { get: storageGet, getMetadata: vi.fn().mockResolvedValue({}) },
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo&tag=old", {
headers: { "cf-connecting-ip": "1.2.3.4" },
@@ -403,7 +812,7 @@ describe("downloads helpers", () => {
runQuery,
runMutation,
scheduler: { runAfter: vi.fn() },
storage: { get: storageGet },
storage: { get: storageGet, getMetadata: vi.fn().mockResolvedValue({}) },
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo&version=1.0.0", {
headers: { "cf-connecting-ip": "1.2.3.4" },
@@ -453,17 +862,21 @@ describe("downloads helpers", () => {
});
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if (Object.keys(args).length === 0) return "https://upload.example";
return { tokenTouched: "tokenId" in args };
});
const runAfter = vi.fn();
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
const storageGet = vi.fn().mockResolvedValue(streamingBlob("hello"));
const response = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter },
storage: { get: storageGet },
storage: {
get: storageGet,
getMetadata: vi.fn().mockResolvedValue({}),
},
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo", {
headers: {
@@ -516,17 +929,21 @@ describe("downloads helpers", () => {
});
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if (Object.keys(args).length === 0) return "https://upload.example";
return { mutationRecorded: true };
});
const runAfter = vi.fn();
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
const storageGet = vi.fn().mockResolvedValue(streamingBlob("hello"));
const response = await downloadZipHandler(
{
runQuery,
runMutation,
scheduler: { runAfter },
storage: { get: storageGet },
storage: {
get: storageGet,
getMetadata: vi.fn().mockResolvedValue({}),
},
} as unknown as ActionCtx,
new Request("https://example.com/api/v1/download?slug=demo", {
headers: { "cf-connecting-ip": "1.2.3.4" },
+261 -13
View File
@@ -5,6 +5,24 @@ import { httpAction } from "./functions";
import { ambiguousSkillSlugResponse } from "./httpApiV1/shared";
import { getOptionalActiveAuthUserIdFromAction } from "./lib/access";
import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth";
import {
ARCHIVE_MANIFEST_AUDIENCE,
ARCHIVE_MANIFEST_CONTENT_TYPE,
ARCHIVE_MANIFEST_JWS_TYPE,
ARCHIVE_METRIC_AUDIENCE,
ARCHIVE_METRIC_JWS_TYPE,
type ArchiveMetricArgs,
type ArchiveMetricPayload,
signArchivePayload,
type SkillArchiveManifest,
verifyArchivePayloadWithLocalJwks,
} from "./lib/archiveManifest";
import {
ARCHIVE_REQUEST_IDENTITY_HEADER,
expectedVercelEnvironmentForConvexSite,
type ClawHubVercelEnvironment,
verifyClawHubVercelOidcToken,
} from "./lib/clawhubVercelOidc";
import {
buildGitHubSkillHandoffDescriptor,
getGitHubHandoffBlock,
@@ -18,14 +36,35 @@ import {
getPublicSkillVersionDownloadBlock,
isSkillVersionForSkill,
} from "./lib/skillFileAccess";
import { buildDeterministicZip } from "./lib/skillZip";
import { buildDeterministicZipStream } from "./lib/skillZip";
const HOUR_MS = 3_600_000;
const DOWNLOAD_STAT_JITTER_MS = 60_000;
const ARCHIVE_MANIFEST_REQUEST_HEADER = "x-clawhub-archive-manifest";
const ARCHIVE_MANIFEST_TTL_MS = 30_000;
const ARCHIVE_MANIFEST_CLOCK_SKEW_MS = 5_000;
const MAX_ARCHIVE_MANIFEST_FILES = 8_192;
const MAX_ARCHIVE_MANIFEST_BYTES = 4 * 1024 * 1024;
const MAX_ARCHIVE_METRIC_TOKEN_BYTES = 16 * 1024;
type DownloadCtx = Parameters<Parameters<typeof httpAction>[0]>[0];
export async function downloadZipHandler(ctx: DownloadCtx, request: Request) {
type DownloadDependencies = {
verifyArchiveRequester: (
token: string,
expectedEnvironment: ClawHubVercelEnvironment,
) => Promise<unknown>;
};
const DEFAULT_DOWNLOAD_DEPENDENCIES: DownloadDependencies = {
verifyArchiveRequester: verifyClawHubVercelOidcToken,
};
export async function downloadZipHandler(
ctx: DownloadCtx,
request: Request,
dependencies: DownloadDependencies = DEFAULT_DOWNLOAD_DEPENDENCIES,
) {
const url = new URL(request.url);
const slug = url.searchParams.get("slug")?.trim().toLowerCase();
const ownerHandle =
@@ -42,6 +81,20 @@ export async function downloadZipHandler(ctx: DownloadCtx, request: Request) {
});
}
const manifestRequested = request.headers.get(ARCHIVE_MANIFEST_REQUEST_HEADER) === "v1";
if (manifestRequested) {
const token = request.headers.get(ARCHIVE_REQUEST_IDENTITY_HEADER)?.trim();
const expectedEnvironment = expectedVercelEnvironmentForConvexSite(request.url);
if (!token || !expectedEnvironment) {
return unauthorizedArchiveManifestResponse();
}
try {
await dependencies.verifyArchiveRequester(token, expectedEnvironment);
} catch {
return unauthorizedArchiveManifestResponse();
}
}
const rate = await applyRateLimit(ctx, request, "download");
if (!rate.ok) return rate.response;
@@ -117,24 +170,77 @@ export async function downloadZipHandler(ctx: DownloadCtx, request: Request) {
});
}
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of version.files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) continue;
const buffer = new Uint8Array(await blob.arrayBuffer());
entries.push({ path: file.path, bytes: buffer });
}
const zipArray = buildDeterministicZip(entries, {
const meta = {
ownerId: String(skill.ownerUserId),
slug: skill.slug,
version: version.version,
publishedAt: version.createdAt,
});
const zipBlob = new Blob([zipArray], { type: "application/zip" });
};
if (manifestRequested) {
if (version.files.length > MAX_ARCHIVE_MANIFEST_FILES) {
return new Response("Skill archive contains too many files", {
status: 413,
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
const entries: Array<{ path: string; url: string }> = [];
for (const file of version.files) {
const fileUrl = await ctx.storage.getUrl(file.storageId);
if (fileUrl) entries.push({ path: file.path, url: fileUrl });
}
const issuedAt = Date.now();
const expiresAt = issuedAt + ARCHIVE_MANIFEST_TTL_MS;
const issuer = url.origin;
const metricToken = await buildArchiveDownloadMetricToken(
ctx,
request,
skill._id,
issuer,
issuedAt,
expiresAt,
);
const manifest: SkillArchiveManifest = {
schema: "clawhub.skill-archive-manifest.v1",
issuer,
audience: ARCHIVE_MANIFEST_AUDIENCE,
issuedAt,
expiresAt,
filename: `${slug}-${version.version}.zip`,
meta,
entries,
...(metricToken ? { metricToken } : {}),
};
const signedManifest = await signArchivePayload(manifest, ARCHIVE_MANIFEST_JWS_TYPE);
if (new TextEncoder().encode(signedManifest).byteLength > MAX_ARCHIVE_MANIFEST_BYTES) {
return new Response("Skill archive manifest is too large", {
status: 413,
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
return new Response(signedManifest, {
status: 200,
headers: mergeHeaders(
rate.headers,
{
"Content-Type": ARCHIVE_MANIFEST_CONTENT_TYPE,
"Cache-Control": "private, no-store",
},
corsHeaders(),
),
});
}
const entries = version.files.map((file) => ({
path: file.path,
openStream: async () => (await ctx.storage.get(file.storageId))?.stream() ?? null,
}));
const zipStream = buildDeterministicZipStream(entries, meta);
await scheduleSkillDownloadMetric(ctx, request, skill._id);
return new Response(zipBlob, {
return new Response(zipStream, {
status: 200,
headers: mergeHeaders(
rate.headers,
@@ -150,6 +256,56 @@ export async function downloadZipHandler(ctx: DownloadCtx, request: Request) {
export const downloadZip = httpAction(downloadZipHandler);
function unauthorizedArchiveManifestResponse() {
return new Response("Unauthorized archive manifest request", {
status: 401,
headers: { "Cache-Control": "no-store" },
});
}
export async function recordArchiveDownloadMetricHandler(ctx: DownloadCtx, request: Request) {
const token = await readBoundedRequestText(request, MAX_ARCHIVE_METRIC_TOKEN_BYTES);
if (!token) {
return new Response("Invalid archive metric capability", {
status: 400,
headers: { "Cache-Control": "no-store" },
});
}
let value: unknown;
try {
value = await verifyArchivePayloadWithLocalJwks(token, ARCHIVE_METRIC_JWS_TYPE);
} catch {
return new Response("Invalid archive metric capability", {
status: 401,
headers: { "Cache-Control": "no-store" },
});
}
const payload = parseArchiveMetricPayload(value, new URL(request.url).origin, Date.now());
if (!payload) {
return new Response("Invalid archive metric capability", {
status: 401,
headers: { "Cache-Control": "no-store" },
});
}
try {
await ctx.scheduler.runAfter(
Math.floor(Math.random() * DOWNLOAD_STAT_JITTER_MS),
internal.downloadMetrics.recordDownloadMetricInternal,
{
...payload.metric,
target: { kind: "skill", id: payload.metric.target.id as Id<"skills"> },
},
);
} catch {
// Metrics remain best-effort and must not affect an archive already being streamed.
}
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
}
export const recordArchiveDownloadMetric = httpAction(recordArchiveDownloadMetricHandler);
export function getHourStart(timestamp: number) {
return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
}
@@ -222,6 +378,98 @@ export async function scheduleSkillDownloadMetric(
}
}
async function buildArchiveDownloadMetricToken(
ctx: DownloadCtx,
request: Request,
skillId: Id<"skills">,
issuer: string,
issuedAt: number,
expiresAt: number,
) {
try {
const userId = await getOptionalDownloadUserId(ctx, request);
const identity = getDownloadIdentity(request, userId ? String(userId) : null);
if (!identity) return undefined;
const metricArgs = await buildDownloadMetricArgs({
target: { kind: "skill", id: skillId },
identity,
now: issuedAt,
});
const metric: ArchiveMetricArgs = {
...metricArgs,
target: { kind: "skill", id: String(skillId) },
};
const payload: ArchiveMetricPayload = {
schema: "clawhub.archive-download-metric.v1",
issuer,
audience: ARCHIVE_METRIC_AUDIENCE,
issuedAt,
expiresAt,
metric,
};
return await signArchivePayload(payload, ARCHIVE_METRIC_JWS_TYPE);
} catch {
return undefined;
}
}
function parseArchiveMetricPayload(
value: unknown,
expectedIssuer: string,
now: number,
): ArchiveMetricPayload | null {
if (!value || typeof value !== "object") return null;
const payload = value as Partial<ArchiveMetricPayload>;
if (payload.schema !== "clawhub.archive-download-metric.v1") return null;
if (payload.issuer !== expectedIssuer || payload.audience !== ARCHIVE_METRIC_AUDIENCE)
return null;
if (!Number.isFinite(payload.issuedAt) || !Number.isFinite(payload.expiresAt)) return null;
const issuedAt = payload.issuedAt as number;
const expiresAt = payload.expiresAt as number;
if (issuedAt > now + ARCHIVE_MANIFEST_CLOCK_SKEW_MS || expiresAt <= now) return null;
if (expiresAt <= issuedAt || expiresAt - issuedAt > ARCHIVE_MANIFEST_TTL_MS) return null;
if (!payload.metric || typeof payload.metric !== "object") return null;
const metric = payload.metric as Partial<ArchiveMetricArgs>;
if (metric.target?.kind !== "skill" || typeof metric.target.id !== "string") return null;
if (metric.identityKind !== "user" && metric.identityKind !== "ip") return null;
if (typeof metric.identityHash !== "string" || metric.identityHash.length === 0) return null;
if (!Number.isFinite(metric.dayStart) || !Number.isFinite(metric.occurredAt)) return null;
return payload as ArchiveMetricPayload;
}
async function readBoundedRequestText(request: Request, maxBytes: number) {
const contentLength = request.headers.get("content-length");
if (contentLength) {
const declaredBytes = Number.parseInt(contentLength, 10);
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) return null;
}
const reader = request.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
totalBytes += chunk.value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
async function getOptionalDownloadUserId(
ctx: DownloadCtx,
request: Request,
+7 -1
View File
@@ -2,7 +2,7 @@ import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema";
import { httpRouter } from "convex/server";
import { agentSkillsHttp } from "./agentSkillsHttp";
import { auth } from "./auth";
import { downloadZip } from "./downloads";
import { downloadZip, recordArchiveDownloadMetric } from "./downloads";
import {
cliPublishHttp,
cliDeviceCodeHttp,
@@ -100,6 +100,12 @@ http.route({
handler: downloadZip,
});
http.route({
path: "/api/internal/archive-download-metric",
method: "POST",
handler: recordArchiveDownloadMetric,
});
http.route({
path: ApiRoutes.search,
method: "GET",
+70
View File
@@ -0,0 +1,70 @@
import { CompactSign, compactVerify, createLocalJWKSet, importPKCS8 } from "jose";
export const ARCHIVE_MANIFEST_CONTENT_TYPE = "application/vnd.clawhub.skill-archive-manifest+jws";
export const ARCHIVE_MANIFEST_AUDIENCE = "clawhub.nitro-skill-archive";
export const ARCHIVE_MANIFEST_JWS_TYPE = "clawhub-skill-archive+jws";
export const ARCHIVE_METRIC_AUDIENCE = "clawhub.convex-download-metric";
export const ARCHIVE_METRIC_JWS_TYPE = "clawhub-download-metric+jws";
export type ArchiveMetricArgs = {
target: { kind: "skill"; id: string };
identityKind: "user" | "ip";
identityHash: string;
dayStart: number;
occurredAt?: number;
};
export type SkillArchiveManifest = {
schema: "clawhub.skill-archive-manifest.v1";
issuer: string;
audience: typeof ARCHIVE_MANIFEST_AUDIENCE;
issuedAt: number;
expiresAt: number;
filename: string;
meta: {
ownerId: string;
slug: string;
version: string;
publishedAt: number;
};
entries: Array<{ path: string; url: string }>;
metricToken?: string;
};
export type ArchiveMetricPayload = {
schema: "clawhub.archive-download-metric.v1";
issuer: string;
audience: typeof ARCHIVE_METRIC_AUDIENCE;
issuedAt: number;
expiresAt: number;
metric: ArchiveMetricArgs;
};
export async function signArchivePayload(
payload: SkillArchiveManifest | ArchiveMetricPayload,
type: typeof ARCHIVE_MANIFEST_JWS_TYPE | typeof ARCHIVE_METRIC_JWS_TYPE,
privateKeyPem = process.env.JWT_PRIVATE_KEY,
) {
if (!privateKeyPem) throw new Error("JWT_PRIVATE_KEY is required to sign archive capabilities");
const privateKey = await importPKCS8(privateKeyPem, "RS256");
const bytes = Uint8Array.from(new TextEncoder().encode(JSON.stringify(payload)));
return await new CompactSign(bytes)
.setProtectedHeader({ alg: "RS256", typ: type })
.sign(privateKey);
}
export async function verifyArchivePayloadWithLocalJwks(
token: string,
expectedType: typeof ARCHIVE_MANIFEST_JWS_TYPE | typeof ARCHIVE_METRIC_JWS_TYPE,
jwksJson = process.env.JWKS,
): Promise<unknown> {
if (!jwksJson) throw new Error("JWKS is required to verify archive capabilities");
const jwks = JSON.parse(jwksJson) as Parameters<typeof createLocalJWKSet>[0];
const verified = await compactVerify(token, createLocalJWKSet(jwks), {
algorithms: ["RS256"],
});
if (verified.protectedHeader.typ !== expectedType) {
throw new Error("Unexpected archive capability type");
}
return JSON.parse(new TextDecoder().decode(verified.payload)) as unknown;
}
+78
View File
@@ -0,0 +1,78 @@
/* @vitest-environment node */
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import {
CLAWHUB_VERCEL_OWNER_ID,
CLAWHUB_VERCEL_PROJECT,
CLAWHUB_VERCEL_PROJECT_ID,
CLAWHUB_VERCEL_TEAM,
expectedVercelEnvironmentForConvexSite,
verifyClawHubVercelOidcToken,
} from "./clawhubVercelOidc";
describe("ClawHub Vercel OIDC", () => {
it("binds each Convex site class to its Vercel environment", () => {
expect(
expectedVercelEnvironmentForConvexSite(
"https://migrated-production.convex.site/api/v1/download",
{ CLAWHUB_ENV: "production" },
),
).toBe("production");
expect(
expectedVercelEnvironmentForConvexSite(
"https://academic-chihuahua-392.convex.site/api/v1/download",
{ CLAWHUB_ENV: "test" },
),
).toBe("preview");
expect(
expectedVercelEnvironmentForConvexSite(
"https://preview-branch-123.convex.site/api/v1/download",
{ CLAWHUB_PREVIEW: "1" },
),
).toBe("preview");
expect(expectedVercelEnvironmentForConvexSite("http://127.0.0.1:3211/api/v1/download")).toBe(
"development",
);
expect(
expectedVercelEnvironmentForConvexSite("https://attacker.example/api/v1/download", {
CLAWHUB_ENV: "production",
}),
).toBeNull();
expect(
expectedVercelEnvironmentForConvexSite(
"https://unclassified.convex.site/api/v1/download",
{},
),
).toBeNull();
});
it("accepts only the ClawHub project identity for the expected environment", async () => {
const keyPair = await generateKeyPair("RS256", { extractable: true });
const publicKey = await exportJWK(keyPair.publicKey);
const jwks = createLocalJWKSet({ keys: [{ use: "sig", ...publicKey }] });
const token = await new SignJWT({
owner_id: CLAWHUB_VERCEL_OWNER_ID,
project_id: CLAWHUB_VERCEL_PROJECT_ID,
environment: "preview",
})
.setProtectedHeader({ alg: "RS256" })
.setIssuer(`https://oidc.vercel.com/${CLAWHUB_VERCEL_TEAM}`)
.setAudience(`https://vercel.com/${CLAWHUB_VERCEL_TEAM}`)
.setSubject(
`owner:${CLAWHUB_VERCEL_TEAM}:project:${CLAWHUB_VERCEL_PROJECT}:environment:preview`,
)
.setIssuedAt()
.setExpirationTime("5m")
.sign(keyPair.privateKey);
await expect(verifyClawHubVercelOidcToken(token, "preview", jwks)).resolves.toMatchObject({
owner_id: CLAWHUB_VERCEL_OWNER_ID,
project_id: CLAWHUB_VERCEL_PROJECT_ID,
environment: "preview",
});
await expect(verifyClawHubVercelOidcToken(token, "production", jwks)).rejects.toThrow(
"Invalid ClawHub Vercel identity",
);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose";
export const CLAWHUB_VERCEL_OWNER_ID = "team_pLdjXbfy0XvPRiNmAygTjTSH";
export const CLAWHUB_VERCEL_PROJECT_ID = "prj_UVAJPNPYrBwTEkPJwkpEySsge8Mc";
export const CLAWHUB_VERCEL_TEAM = "openclaw-foundation";
export const CLAWHUB_VERCEL_PROJECT = "clawhub";
export const ARCHIVE_REQUEST_IDENTITY_HEADER = "x-clawhub-vercel-oidc-token";
const VERCEL_OIDC_ISSUER = `https://oidc.vercel.com/${CLAWHUB_VERCEL_TEAM}`;
const VERCEL_OIDC_AUDIENCE = `https://vercel.com/${CLAWHUB_VERCEL_TEAM}`;
const VERCEL_OIDC_JWKS = createRemoteJWKSet(new URL(`${VERCEL_OIDC_ISSUER}/.well-known/jwks`));
type ClawHubArchiveRuntimeEnvironment = {
CLAWHUB_ENV?: string;
CLAWHUB_PREVIEW?: string;
};
export type ClawHubVercelEnvironment = "development" | "preview" | "production";
export function expectedVercelEnvironmentForConvexSite(
requestUrl: string,
env: ClawHubArchiveRuntimeEnvironment = process.env,
): ClawHubVercelEnvironment | null {
let url: URL;
try {
url = new URL(requestUrl);
} catch {
return null;
}
if (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
return "development";
}
if (url.protocol !== "https:" || !url.hostname.endsWith(".convex.site")) return null;
const runtimeEnvironment = env.CLAWHUB_ENV?.trim();
if (env.CLAWHUB_PREVIEW === "1") {
return runtimeEnvironment && runtimeEnvironment !== "preview" ? null : "preview";
}
if (runtimeEnvironment === "production") return "production";
// ClawHub Test is an app-level label on a Vercel preview-target deployment.
if (runtimeEnvironment === "test") return "preview";
return null;
}
export async function verifyClawHubVercelOidcToken(
token: string,
expectedEnvironment: ClawHubVercelEnvironment,
jwks: JWTVerifyGetKey = VERCEL_OIDC_JWKS,
) {
const verified = await jwtVerify(token, jwks, {
algorithms: ["RS256"],
issuer: VERCEL_OIDC_ISSUER,
audience: VERCEL_OIDC_AUDIENCE,
});
const payload = verified.payload;
if (
payload.owner_id !== CLAWHUB_VERCEL_OWNER_ID ||
payload.project_id !== CLAWHUB_VERCEL_PROJECT_ID ||
payload.environment !== expectedEnvironment ||
payload.sub !==
`owner:${CLAWHUB_VERCEL_TEAM}:project:${CLAWHUB_VERCEL_PROJECT}:environment:${expectedEnvironment}`
) {
throw new Error("Invalid ClawHub Vercel identity");
}
return payload;
}
+157 -1
View File
@@ -1,11 +1,16 @@
import { findClawPackagePathHierarchyCollision, isSafeClawPackagePath } from "clawhub-schema";
import { zipSync } from "fflate";
import { Zip, ZipDeflate, zipSync } from "fflate";
type ZipEntry = {
path: string;
bytes: Uint8Array;
};
export type AsyncZipEntry = {
path: string;
openStream: () => Promise<ReadableStream<Uint8Array> | null>;
};
export type SkillZipMeta = {
ownerId: string;
slug: string;
@@ -16,6 +21,9 @@ export type SkillZipMeta = {
type ZipInput = Record<string, Uint8Array | [Uint8Array, { mtime?: Date }]>;
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0);
// Storage response chunk boundaries vary with transport backpressure; normalize
// them so identical files still produce byte-for-byte identical archives.
const ZIP_INPUT_CHUNK_BYTES = 64 * 1024;
// ==================== Zip Slip Protection ====================
@@ -68,6 +76,154 @@ export function buildDeterministicZip(entries: ZipEntry[], meta?: SkillZipMeta)
return Uint8Array.from(zipSync(zipData, { level: 6 }));
}
export function buildDeterministicZipStream(entries: AsyncZipEntry[], meta?: SkillZipMeta) {
const orderedEntries = orderZipEntries(entries, meta);
const output: Uint8Array[] = [];
let entryIndex = 0;
let current:
| {
reader: ReadableStreamDefaultReader<Uint8Array>;
zipEntry: ZipDeflate;
inputBuffer: Uint8Array;
inputBufferLength: number;
sourceChunk?: Uint8Array;
sourceOffset: number;
sourceDone: boolean;
}
| undefined;
let archiveEnded = false;
let archiveDone = false;
let archiveError: unknown;
const archive = new Zip((error, chunk, final) => {
if (error) archiveError = error;
if (chunk?.length) output.push(chunk);
if (final) archiveDone = true;
});
const advance = async () => {
if (archiveError) throw archiveError;
if (current) {
while (current.inputBufferLength < ZIP_INPUT_CHUNK_BYTES && !current.sourceDone) {
if (!current.sourceChunk || current.sourceOffset === current.sourceChunk.byteLength) {
const next = await current.reader.read();
if (next.done) {
current.sourceDone = true;
break;
}
current.sourceChunk = next.value;
current.sourceOffset = 0;
if (next.value.byteLength === 0) continue;
}
const sourceBytesRemaining = current.sourceChunk.byteLength - current.sourceOffset;
const outputBytesRemaining = ZIP_INPUT_CHUNK_BYTES - current.inputBufferLength;
const bytesToCopy = Math.min(sourceBytesRemaining, outputBytesRemaining);
current.inputBuffer.set(
current.sourceChunk.subarray(current.sourceOffset, current.sourceOffset + bytesToCopy),
current.inputBufferLength,
);
current.sourceOffset += bytesToCopy;
current.inputBufferLength += bytesToCopy;
}
if (current.inputBufferLength > 0) {
current.zipEntry.push(
current.inputBuffer.subarray(0, current.inputBufferLength),
current.sourceDone,
);
current.inputBuffer = new Uint8Array(ZIP_INPUT_CHUNK_BYTES);
current.inputBufferLength = 0;
} else if (current.sourceDone) {
current.zipEntry.push(new Uint8Array(0), true);
}
if (current.sourceDone) {
current.reader.releaseLock();
current = undefined;
}
if (archiveError) throw archiveError;
return;
}
while (entryIndex < orderedEntries.length) {
const entry = orderedEntries[entryIndex++];
const stream = await entry.openStream();
// A storage reference can become stale after the version document was read.
// Do not commit a ZIP header until the Blob is known to still exist.
if (!stream) continue;
const zipEntry = new ZipDeflate(entry.path, { level: 6 });
zipEntry.mtime = FIXED_ZIP_DATE;
archive.add(zipEntry);
current = {
reader: stream.getReader(),
zipEntry,
inputBuffer: new Uint8Array(ZIP_INPUT_CHUNK_BYTES),
inputBufferLength: 0,
sourceOffset: 0,
sourceDone: false,
};
return;
}
if (!archiveEnded) {
archiveEnded = true;
archive.end();
if (archiveError) throw archiveError;
}
};
return new ReadableStream<Uint8Array>(
{
async pull(controller) {
try {
for (;;) {
if (output.length > 0 || archiveDone) break;
await advance();
}
const chunk = output.shift();
if (chunk) controller.enqueue(chunk);
else controller.close();
} catch (error) {
archive.terminate();
await current?.reader.cancel(error);
controller.error(error);
}
},
async cancel(reason) {
archive.terminate();
await current?.reader.cancel(reason);
},
},
{ highWaterMark: 0 },
);
}
function orderZipEntries(entries: AsyncZipEntry[], meta?: SkillZipMeta) {
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
const byPath = new Map(sorted.map((entry) => [entry.path, entry]));
const zipDataOrder: Record<string, true> = {};
for (const entry of sorted) zipDataOrder[entry.path] = true;
if (meta) {
const metaBytes = new TextEncoder().encode(JSON.stringify(buildSkillMeta(meta), null, 2));
byPath.set("_meta.json", {
path: "_meta.json",
openStream: async () =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(metaBytes);
controller.close();
},
}),
});
zipDataOrder["_meta.json"] = true;
}
return Object.keys(zipDataOrder).map((path) => byPath.get(path)!);
}
export function buildDeterministicPackageZip(entries: ZipEntry[]) {
const unsafeEntry = entries.find((entry) => !isSafeClawPackagePath(entry.path));
if (unsafeEntry) {
+3
View File
@@ -1315,6 +1315,9 @@ Notes:
- If neither `version` nor `tag` is provided, the latest version is used.
- Soft-deleted versions return `410`.
- Hosted skill versions return a streamed deterministic ZIP with
`Content-Disposition: attachment; filename="<slug>-<version>.zip"`. ClawHub
applies moderation, rate limiting, and download metering before streaming.
- GitHub-backed skill handoffs do not proxy or mirror bytes. The JSON response
includes `sourceRef: "public-github"`, `repo`, `commit`, `path`, `contentHash`,
and `archiveUrl`; scan/current state is a gate and is not included as success
+1
View File
@@ -129,6 +129,7 @@
"fflate": "0.8.3",
"h3": "2.0.1-rc.25",
"ignore": "7.0.6",
"jose": "6.2.3",
"lucide-react": "1.28.0",
"mermaid": "^11.16.1",
"mime": "4.1.0",
+14 -14
View File
@@ -1682,25 +1682,25 @@
}
}
],
"responses": {
"200": {
"description": "Hosted ZIP bytes or a GitHub-backed source handoff descriptor.",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitHubSkillDownloadHandoff"
}
"responses": {
"200": {
"description": "Hosted ZIP bytes or a GitHub-backed source handoff descriptor.",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/GitHubSkillDownloadHandoff"
}
}
}
}
}
}
},
"/api/v1/packages": {
"get": {
+520
View File
@@ -1,16 +1,37 @@
/* @vitest-environment node */
import { unzipSync } from "fflate";
import { mockEvent } from "h3";
import { createLocalJWKSet, exportJWK, exportPKCS8, generateKeyPair } from "jose";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ARCHIVE_MANIFEST_AUDIENCE,
ARCHIVE_MANIFEST_CONTENT_TYPE,
ARCHIVE_MANIFEST_JWS_TYPE,
signArchivePayload,
type SkillArchiveManifest,
} from "../convex/lib/archiveManifest";
import {
buildConvexProxyTarget,
isConvexProxyMethodAllowed,
proxyConvexRequest,
resolveConvexStorageOrigin,
resolveConvexProxyEnv,
verifySignedArchiveManifest,
} from "./convexProxy";
const TEST_ARCHIVE_DEPENDENCIES = {
getArchiveRequestToken: async () => "verified-vercel-oidc",
verifyArchiveManifest: async (token: string) => ({
...(JSON.parse(token) as Record<string, unknown>),
issuer: "https://preview-branch-123.convex.site",
audience: ARCHIVE_MANIFEST_AUDIENCE,
}),
};
describe("Convex HTTP proxy", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -69,6 +90,22 @@ describe("Convex HTTP proxy", () => {
});
});
it("accepts storage only from the Convex deployment paired to the selected site", () => {
expect(
resolveConvexStorageOrigin("https://preview-branch-123.convex.site/api/v1/download", {
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
CONVEX_URL: "https://wry-manatee-359.convex.cloud",
}),
).toBe("https://preview-branch-123.convex.cloud");
expect(
resolveConvexStorageOrigin("https://preview-branch-123.convex.site/api/v1/download", {
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://wry-manatee-359.convex.cloud",
}),
).toBeNull();
});
it("proxies reads and exposes the non-secret preview deployment name for proof", async () => {
const fetchMock = vi.fn(async () => {
return new Response(JSON.stringify({ ok: true }), {
@@ -92,6 +129,489 @@ describe("Convex HTTP proxy", () => {
expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123");
});
it("streams hosted downloads from a Convex manifest with the final attachment filename", async () => {
const storedBody = new TextEncoder().encode("# streamed skill\n");
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = input.toString();
if (url.startsWith("https://preview-branch-123.convex.site/api/v1/download")) {
return Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0+build.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0+build",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
{
path: "stale.txt",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-missing",
},
],
metricToken: "signed-metric-capability",
},
{
headers: {
"cache-control": "private, no-store",
"content-digest": "sha-256=:manifest-digest:",
"content-encoding": "gzip",
"content-type": ARCHIVE_MANIFEST_CONTENT_TYPE,
etag: '"manifest-etag"',
"x-ratelimit-remaining": "49",
},
},
);
}
if (url === "https://preview-branch-123.convex.cloud/api/storage/storage-1") {
return new Response(storedBody, { status: 200 });
}
if (url === "https://preview-branch-123.convex.cloud/api/storage/storage-missing") {
return new Response("missing", { status: 404 });
}
if (url === "https://preview-branch-123.convex.site/api/internal/archive-download-metric") {
return new Response(null, { status: 204 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const event = mockEvent("https://preview.example/api/v1/download?slug=demo", {
headers: { "x-clawhub-vercel-oidc-token": "client-forgery" },
});
const response = await proxyConvexRequest(
event,
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/zip");
expect(response.headers.get("Content-Disposition")).toBe(
'attachment; filename="demo-1.0.0+build.zip"',
);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("Content-Digest")).toBeNull();
expect(response.headers.get("Content-Encoding")).toBeNull();
expect(response.headers.get("ETag")).toBeNull();
expect(response.headers.get("X-RateLimit-Remaining")).toBe("49");
expect(response.headers.get("X-ClawHub-Preview-Backend")).toBe("preview-branch-123");
const archive = unzipSync(new Uint8Array(await response.arrayBuffer()));
expect(archive["SKILL.md"]).toEqual(storedBody);
expect(Object.keys(archive).sort()).toEqual(["SKILL.md", "_meta.json"]);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://preview-branch-123.convex.site/api/v1/download?slug=demo",
);
expect(
new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("x-clawhub-archive-manifest"),
).toBe("v1");
expect(
new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("x-clawhub-vercel-oidc-token"),
).toBe("verified-vercel-oidc");
const metricCall = fetchMock.mock.calls.find(
([input]) =>
input.toString() ===
"https://preview-branch-123.convex.site/api/internal/archive-download-metric",
);
expect(metricCall?.[1]).toMatchObject({
method: "POST",
body: "signed-metric-capability",
});
const fetchedUrls = fetchMock.mock.calls.map(([input]) => input.toString());
expect(
fetchedUrls.indexOf(
"https://preview-branch-123.convex.site/api/internal/archive-download-metric",
),
).toBeGreaterThan(
fetchedUrls.indexOf("https://preview-branch-123.convex.cloud/api/storage/storage-1"),
);
});
it("does not record a download when every source Blob has vanished", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = input.toString();
if (url.startsWith("https://preview-branch-123.convex.site/api/v1/download")) {
return Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [
{
path: "stale.txt",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-missing",
},
],
metricToken: "signed-metric-capability",
},
{ headers: { "content-type": ARCHIVE_MANIFEST_CONTENT_TYPE } },
);
}
if (url === "https://preview-branch-123.convex.cloud/api/storage/storage-missing") {
return new Response("missing", { status: 404 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const response = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
const archive = unzipSync(new Uint8Array(await response.arrayBuffer()));
expect(Object.keys(archive)).toEqual(["_meta.json"]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("rejects an unsigned archive manifest from the paired Convex origin", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = input.toString();
if (url.startsWith("https://preview-branch-123.convex.site/api/v1/download")) {
return Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
],
},
{ headers: { "content-type": "application/vnd.clawhub.skill-archive-manifest+json" } },
);
}
if (url === "https://preview-branch-123.convex.cloud/api/storage/storage-1") {
return new Response("should not be fetched");
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const response = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(502);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("verifies the paired Convex signature and rejects a modified manifest", async () => {
const keyPair = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keyPair.privateKey);
const publicKey = await exportJWK(keyPair.publicKey);
const manifest: SkillArchiveManifest = {
schema: "clawhub.skill-archive-manifest.v1",
issuer: "https://preview-branch-123.convex.site",
audience: ARCHIVE_MANIFEST_AUDIENCE,
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
],
};
const token = await signArchivePayload(manifest, ARCHIVE_MANIFEST_JWS_TYPE, privateKey);
const localJwks = createLocalJWKSet({ keys: [{ use: "sig", ...publicKey }] });
await expect(
verifySignedArchiveManifest(
token,
"https://preview-branch-123.convex.site/api/v1/download",
localJwks,
),
).resolves.toEqual(manifest);
const [header, payload, signature] = token.split(".");
const modifiedPayload = `${payload!.slice(0, -1)}${payload!.endsWith("A") ? "B" : "A"}`;
await expect(
verifySignedArchiveManifest(
`${header}.${modifiedPayload}.${signature}`,
"https://preview-branch-123.convex.site/api/v1/download",
localJwks,
),
).rejects.toThrow();
});
it("produces identical archive bytes when storage streams use different chunk boundaries", async () => {
// Cross many normalization boundaries without making full-suite coverage
// instrumentation dominate the repository's 15-second test timeout.
const storedBody = new Uint8Array(512 * 1024);
let randomState = 0x3451cafe;
for (let index = 0; index < storedBody.length; index += 1) {
randomState ^= randomState << 13;
randomState ^= randomState >>> 17;
randomState ^= randomState << 5;
storedBody[index] = randomState;
}
let storageRequest = 0;
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = input.toString();
if (url.startsWith("https://preview-branch-123.convex.site/api/v1/download")) {
return Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
],
},
{ headers: { "content-type": ARCHIVE_MANIFEST_CONTENT_TYPE } },
);
}
if (url === "https://preview-branch-123.convex.cloud/api/storage/storage-1") {
const chunkSize = storageRequest++ === 0 ? 16_381 : 65_521;
let offset = 0;
return new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
if (offset >= storedBody.length) {
controller.close();
return;
}
const nextOffset = Math.min(offset + chunkSize, storedBody.length);
controller.enqueue(storedBody.slice(offset, nextOffset));
offset = nextOffset;
},
}),
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const env = {
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
};
const firstResponse = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
env,
TEST_ARCHIVE_DEPENDENCIES,
);
const firstArchive = new Uint8Array(await firstResponse.arrayBuffer());
const secondResponse = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
env,
TEST_ARCHIVE_DEPENDENCIES,
);
const secondArchive = new Uint8Array(await secondResponse.arrayBuffer());
expect(secondArchive.byteLength).toBe(firstArchive.byteLength);
expect(secondArchive.every((byte, index) => byte === firstArchive[index])).toBe(true);
expect(unzipSync(secondArchive)["SKILL.md"]).toEqual(storedBody);
});
it("rejects archive manifests that point outside the paired Convex storage origin", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = input.toString();
if (url.startsWith("https://preview-branch-123.convex.site/api/v1/download")) {
return Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 31_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [{ path: "SKILL.md", url: "https://attacker.example/private" }],
},
{ headers: { "content-type": ARCHIVE_MANIFEST_CONTENT_TYPE } },
);
}
throw new Error(`Security boundary crossed: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_000);
const event = mockEvent("https://preview.example/api/v1/download?slug=demo");
const response = await proxyConvexRequest(
event,
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(502);
expect(await response.text()).toBe("Invalid or expired archive manifest");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("rejects an expired archive manifest before fetching any stored file", async () => {
const fetchMock = vi.fn(async () =>
Response.json(
{
schema: "clawhub.skill-archive-manifest.v1",
issuedAt: 1_000,
expiresAt: 2_000,
filename: "demo-1.0.0.zip",
meta: {
ownerId: "users:1",
slug: "demo",
version: "1.0.0",
publishedAt: 3,
},
entries: [
{
path: "SKILL.md",
url: "https://preview-branch-123.convex.cloud/api/storage/storage-1",
},
],
},
{ headers: { "content-type": ARCHIVE_MANIFEST_CONTENT_TYPE } },
),
);
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(Date, "now").mockReturnValue(2_001);
const response = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(502);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not accept a client-supplied manifest replay", async () => {
const fetchMock = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response("Skill not found", { status: 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const event = mockEvent("https://preview.example/api/v1/download?slug=demo&manifest=replayed", {
headers: { "x-clawhub-archive-manifest": "replayed-v0" },
});
const response = await proxyConvexRequest(
event,
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(404);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://preview-branch-123.convex.site/api/v1/download?slug=demo&manifest=replayed",
);
expect(
new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("x-clawhub-archive-manifest"),
).toBe("v1");
});
it("rejects an oversized manifest body", async () => {
const bodyRead = vi.fn();
const fetchMock = vi.fn(
async () =>
new Response(
new ReadableStream({
pull(controller) {
bodyRead();
controller.enqueue(new Uint8Array(1024 * 1024));
},
}),
{
headers: {
"content-type": ARCHIVE_MANIFEST_CONTENT_TYPE,
},
},
),
);
vi.stubGlobal("fetch", fetchMock);
const response = await proxyConvexRequest(
mockEvent("https://preview.example/api/v1/download?slug=demo"),
{
VERCEL_ENV: "preview",
VITE_CONVEX_SITE_URL: "https://preview-branch-123.convex.site",
VITE_CONVEX_URL: "https://preview-branch-123.convex.cloud",
},
TEST_ARCHIVE_DEPENDENCIES,
);
expect(response.status).toBe(502);
expect(bodyRead.mock.calls.length).toBeGreaterThanOrEqual(5);
expect(bodyRead.mock.calls.length).toBeLessThanOrEqual(6);
});
it("exposes the permanent Test backend name for deployment proof", async () => {
vi.stubGlobal(
"fetch",
+318 -1
View File
@@ -1,6 +1,44 @@
import { getVercelOidcToken } from "@vercel/oidc";
import { defineEventHandler, getRequestURL, proxyRequest, type H3Event } from "h3";
import { compactVerify, type CompactVerifyGetKey, createRemoteJWKSet } from "jose";
import {
ARCHIVE_MANIFEST_AUDIENCE,
ARCHIVE_MANIFEST_CONTENT_TYPE,
ARCHIVE_MANIFEST_JWS_TYPE,
type SkillArchiveManifest,
} from "../convex/lib/archiveManifest";
import {
ARCHIVE_REQUEST_IDENTITY_HEADER,
CLAWHUB_VERCEL_PROJECT,
CLAWHUB_VERCEL_TEAM,
} from "../convex/lib/clawhubVercelOidc";
import {
buildDeterministicZipStream,
type SkillZipMeta,
validateFilePath,
validateSlug,
} from "../convex/lib/skillZip";
import { convexDeploymentName, resolveConvexSiteUrl } from "../src/lib/convexDeploymentUrl";
const ARCHIVE_MANIFEST_REQUEST_HEADER = "x-clawhub-archive-manifest";
const ARCHIVE_MANIFEST_MAX_AGE_MS = 60_000;
const ARCHIVE_MANIFEST_CLOCK_SKEW_MS = 5_000;
const MAX_ARCHIVE_MANIFEST_ENTRIES = 8_192;
const MAX_ARCHIVE_ENTRY_URL_LENGTH = 4_096;
const MAX_ARCHIVE_MANIFEST_BYTES = 4 * 1024 * 1024;
const ARCHIVE_FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,499}\.zip$/;
const ARCHIVE_REPRESENTATION_HEADERS = [
"accept-ranges",
"content-digest",
"content-encoding",
"content-length",
"content-md5",
"content-range",
"digest",
"etag",
"last-modified",
] as const;
type ProxyEnv = {
CONVEX_URL?: string;
VERCEL_ENV?: string;
@@ -10,6 +48,18 @@ type ProxyEnv = {
VITE_CONVEX_URL?: string;
};
type ProxyDependencies = {
getArchiveRequestToken: () => Promise<string>;
verifyArchiveManifest: (token: string, target: string) => Promise<unknown>;
};
const DEFAULT_PROXY_DEPENDENCIES: ProxyDependencies = {
getArchiveRequestToken: () =>
getVercelOidcToken({ team: CLAWHUB_VERCEL_TEAM, project: CLAWHUB_VERCEL_PROJECT }),
verifyArchiveManifest: verifySignedArchiveManifest,
};
const archiveJwksByOrigin = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
const BUNDLED_PROXY_ENV: ProxyEnv = {
VITE_CLAWHUB_DEPLOY_ENV: import.meta.env.VITE_CLAWHUB_DEPLOY_ENV,
VITE_CONVEX_SITE_URL: import.meta.env.VITE_CONVEX_SITE_URL,
@@ -62,6 +112,7 @@ export function buildConvexProxyTarget(pathAndQuery: string, env: ProxyEnv) {
export async function proxyConvexRequest(
event: H3Event,
env: ProxyEnv = resolveConvexProxyEnv(process.env),
dependencies: ProxyDependencies = DEFAULT_PROXY_DEPENDENCIES,
): Promise<Response> {
if (!isConvexProxyMethodAllowed(event.req.method, env)) {
return new Response("Disposable previews are read-only.", {
@@ -76,7 +127,30 @@ export async function proxyConvexRequest(
const requestUrl = getRequestURL(event);
const target = buildConvexProxyTarget(`${requestUrl.pathname}${requestUrl.search}`, env);
const proxied = await proxyRequest(event, target);
const isArchiveRequest = isSkillDownloadPath(new URL(target).pathname);
let archiveRequestToken: string | undefined;
if (isArchiveRequest) {
try {
archiveRequestToken = await dependencies.getArchiveRequestToken();
} catch {
return new Response("Archive streaming identity unavailable", {
status: 503,
headers: { "Cache-Control": "no-store" },
});
}
}
const proxied = await proxyRequest(event, target, {
...(isArchiveRequest
? {
fetchOptions: {
headers: {
[ARCHIVE_MANIFEST_REQUEST_HEADER]: "v1",
[ARCHIVE_REQUEST_IDENTITY_HEADER]: archiveRequestToken!,
},
},
}
: {}),
});
// H3's HTTPResponse is not guaranteed to share Nitro's bundled class identity.
// Normalize it before crossing that boundary or Nitro can stringify the wrapper.
const response = new Response(proxied.body, {
@@ -84,6 +158,22 @@ export async function proxyConvexRequest(
statusText: proxied.statusText,
headers: proxied.headers,
});
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
if (contentType === ARCHIVE_MANIFEST_CONTENT_TYPE) {
return streamSkillArchive(
response,
env,
target,
event.req.signal,
dependencies.verifyArchiveManifest,
);
}
if (
isSkillDownloadPath(new URL(target).pathname) &&
contentType?.startsWith("application/vnd.clawhub.skill-archive-manifest")
) {
return new Response("Invalid or expired archive manifest", { status: 502 });
}
if (isPreviewFrontend(env) || isTestFrontend(env)) {
const deployment = convexDeploymentName(target);
if (deployment) {
@@ -96,4 +186,231 @@ export async function proxyConvexRequest(
return response;
}
function isSkillDownloadPath(pathname: string) {
return pathname === "/api/v1/download" || pathname === "/api/download";
}
async function streamSkillArchive(
manifestResponse: Response,
env: ProxyEnv,
target: string,
signal: AbortSignal,
verifyArchiveManifest: ProxyDependencies["verifyArchiveManifest"],
) {
let value: unknown;
try {
const token = await readBoundedArchiveManifest(manifestResponse);
if (!token) return new Response("Invalid or expired archive manifest", { status: 502 });
value = await verifyArchiveManifest(token, target);
} catch {
return new Response("Invalid or expired archive manifest", { status: 502 });
}
const expectedStorageOrigin = resolveConvexStorageOrigin(target, env);
const manifest = parseSkillArchiveManifest(
value,
new URL(target).origin,
expectedStorageOrigin,
Date.now(),
);
if (!manifest) {
return new Response("Invalid or expired archive manifest", { status: 502 });
}
let metricRecorded = false;
const recordMetric = async () => {
if (metricRecorded || !manifest.metricToken) return;
metricRecorded = true;
try {
await fetch(new URL("/api/internal/archive-download-metric", target), {
method: "POST",
headers: { "content-type": "application/jose" },
body: manifest.metricToken,
});
} catch {
// Download metrics remain best-effort and never interrupt archive bytes.
}
};
const stream = buildDeterministicZipStream(
manifest.entries.map((entry) => ({
path: entry.path,
openStream: async () => {
const response = await fetch(entry.url, { redirect: "error", signal });
if (response.status === 404) return null;
if (!response.ok || !response.body) {
throw new Error(`Failed to fetch archive entry: ${response.status}`);
}
await recordMetric();
return response.body;
},
})),
manifest.meta,
);
const headers = new Headers(manifestResponse.headers);
for (const name of ARCHIVE_REPRESENTATION_HEADERS) headers.delete(name);
headers.set("content-type", "application/zip");
headers.set("content-disposition", `attachment; filename="${manifest.filename}"`);
const response = new Response(stream, { status: 200, headers });
if (isPreviewFrontend(env) || isTestFrontend(env)) {
const deployment = convexDeploymentName(target);
if (deployment) {
response.headers.set(
isTestFrontend(env) ? "X-ClawHub-Test-Backend" : "X-ClawHub-Preview-Backend",
deployment,
);
}
}
return response;
}
async function readBoundedArchiveManifest(response: Response) {
const contentLength = response.headers.get("content-length");
if (contentLength) {
const declaredBytes = Number.parseInt(contentLength, 10);
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_ARCHIVE_MANIFEST_BYTES) return null;
}
const reader = response.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
totalBytes += chunk.value.byteLength;
if (totalBytes > MAX_ARCHIVE_MANIFEST_BYTES) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function parseSkillArchiveManifest(
value: unknown,
expectedIssuer: string,
expectedStorageOrigin: string | null,
now: number,
): SkillArchiveManifest | null {
if (!value || typeof value !== "object") return null;
const manifest = value as Partial<SkillArchiveManifest>;
if (manifest.schema !== "clawhub.skill-archive-manifest.v1") return null;
if (
manifest.issuer !== expectedIssuer ||
manifest.audience !== ARCHIVE_MANIFEST_AUDIENCE ||
!expectedStorageOrigin
) {
return null;
}
if (!Number.isFinite(manifest.issuedAt) || !Number.isFinite(manifest.expiresAt)) return null;
const issuedAt = manifest.issuedAt as number;
const expiresAt = manifest.expiresAt as number;
if (issuedAt > now + ARCHIVE_MANIFEST_CLOCK_SKEW_MS || expiresAt <= now) return null;
if (expiresAt <= issuedAt || expiresAt - issuedAt > ARCHIVE_MANIFEST_MAX_AGE_MS) return null;
if (typeof manifest.filename !== "string" || !ARCHIVE_FILENAME_PATTERN.test(manifest.filename)) {
return null;
}
if (!isSkillZipMeta(manifest.meta)) return null;
if (!Array.isArray(manifest.entries) || manifest.entries.length > MAX_ARCHIVE_MANIFEST_ENTRIES) {
return null;
}
if (
manifest.metricToken !== undefined &&
(typeof manifest.metricToken !== "string" || manifest.metricToken.length > 16 * 1024)
) {
return null;
}
const seenPaths = new Set<string>();
for (const entry of manifest.entries) {
if (!entry || typeof entry !== "object") return null;
if (typeof entry.path !== "string" || !validateFilePath(entry.path)) return null;
if (seenPaths.has(entry.path)) return null;
seenPaths.add(entry.path);
if (typeof entry.url !== "string" || entry.url.length > MAX_ARCHIVE_ENTRY_URL_LENGTH) {
return null;
}
let url: URL;
try {
url = new URL(entry.url);
} catch {
return null;
}
if (url.username || url.password || url.origin !== expectedStorageOrigin) return null;
if (!url.pathname.startsWith("/api/storage/")) return null;
}
return manifest as SkillArchiveManifest;
}
function isSkillZipMeta(value: unknown): value is SkillZipMeta {
if (!value || typeof value !== "object") return false;
const meta = value as Partial<SkillZipMeta>;
return (
typeof meta.ownerId === "string" &&
typeof meta.slug === "string" &&
validateSlug(meta.slug) &&
typeof meta.version === "string" &&
meta.version.length > 0 &&
meta.version.length <= 200 &&
typeof meta.publishedAt === "number" &&
Number.isFinite(meta.publishedAt)
);
}
export async function verifySignedArchiveManifest(
token: string,
target: string,
jwksOverride?: CompactVerifyGetKey,
) {
const targetOrigin = new URL(target).origin;
let jwks = jwksOverride ?? archiveJwksByOrigin.get(targetOrigin);
if (!jwks) {
const remoteJwks = createRemoteJWKSet(new URL("/.well-known/jwks.json", targetOrigin));
archiveJwksByOrigin.set(targetOrigin, remoteJwks);
jwks = remoteJwks;
}
const verified = await compactVerify(token, jwks, { algorithms: ["RS256"] });
if (verified.protectedHeader.typ !== ARCHIVE_MANIFEST_JWS_TYPE) {
throw new Error("Unexpected archive manifest signature type");
}
return JSON.parse(new TextDecoder().decode(verified.payload)) as unknown;
}
export function resolveConvexStorageOrigin(target: string, env: ProxyEnv) {
let targetOrigin: string;
let selectedSiteOrigin: string;
let cloudUrl: URL;
try {
targetOrigin = new URL(target).origin;
selectedSiteOrigin = resolveConvexSiteUrl(env);
cloudUrl = new URL(env.VITE_CONVEX_URL ?? env.CONVEX_URL ?? "");
} catch {
return null;
}
if (targetOrigin !== selectedSiteOrigin) return null;
const isLocalCloud =
cloudUrl.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(cloudUrl.hostname);
if (isLocalCloud) return cloudUrl.origin;
if (cloudUrl.protocol !== "https:" || !cloudUrl.hostname.endsWith(".convex.cloud")) {
return null;
}
const targetDeployment = convexDeploymentName(target);
const cloudDeployment = cloudUrl.hostname.slice(0, -".convex.cloud".length);
if (targetDeployment && targetDeployment !== cloudDeployment) return null;
return cloudUrl.origin;
}
export default defineEventHandler((event) => proxyConvexRequest(event));
+33 -2
View File
@@ -163,7 +163,37 @@ Local fixture data lives in `convex/devSeed.ts` and `fixtures/public-corpus/`.
## Download API
- JSON API for skill metadata + versions.
- Download endpoint returns zip of a version (HTTP action).
- Convex remains the download control plane: it resolves the version, applies
moderation and rate limits, preserves auth-derived metering, and returns the
Nitro API owner a bounded, no-store manifest of Convex File Storage URLs.
- Nitro streams those source files into the deterministic ZIP with backpressure
and owns the public response headers. Archive bytes must not pass through a
Convex HTTP action because those responses are capped at 20 MiB.
- The manifest is an internal server-to-server capability, not a client token.
Nitro overwrites the internal request headers and authenticates to Convex with
its Vercel OIDC identity; Convex verifies the Vercel signature plus the exact
ClawHub team, project, subject, audience, and target environment before
returning any storage URL. The permanent ClawHub Test frontend is a Vercel
preview-target deployment, so its OIDC environment and subject use `preview`;
the app-level `test` label is not an OIDC trust claim. Convex derives this
expected target from its explicit runtime environment markers, not a fixed
deployment hostname, and fails closed when a remote runtime is unclassified.
Nitro never accepts a
client-supplied manifest and
accepts Convex's response only as a short-lived RS256 JWS signed by the
selected Convex deployment's existing auth key and verified from that
deployment's JWKS endpoint. The signed issuer, audience, type, and time bounds
must match the request, and source URLs are allowed only on the single
build-paired Convex deployment's `/api/storage/` surface. Convex storage URLs
are reusable bearer URLs, so they must never appear in the public response.
Nitro preserves control-plane headers such as rate-limit state but discards
manifest representation metadata before emitting the generated ZIP headers.
- Download metering is also a signed, short-lived capability. It contains only
the existing pre-hashed identity and metric arguments, stays inside the
Convex-to-Nitro boundary, and is returned to Convex only after Nitro opens the
first live source Blob. An archive whose source Blobs are all stale must not
count. Capability replay remains harmless because the existing
target/identity/day metric mutation is idempotent.
- Soft-delete versions; downloads remain for non-deleted versions only.
## UI (SPA)
@@ -187,5 +217,6 @@ Local fixture data lives in `convex/devSeed.ts` and `fixtures/public-corpus/`.
## Open questions (carry forward)
- Embeddings provider key + rate limits.
- Zip generation memory limits (optimize with streaming if needed).
- ZIP generation must remain backpressured; never buffer a whole stored entry
or completed archive in either Convex or Nitro.
- GitHub App repo sync (phase 2).
+1 -1
View File
@@ -215,7 +215,7 @@ describe("SkillDetailTabs README links", () => {
);
const href = screen.getByRole("link", { name: "Download version v1.0.0" }).getAttribute("href");
const url = new URL(href ?? "");
const url = new URL(href ?? "", "https://clawhub.ai");
expect(url.pathname).toBe("/api/v1/download");
expect(url.searchParams.get("slug")).toBe("api-gateway");
expect(url.searchParams.get("ownerHandle")).toBe("clawkit");
+3 -10
View File
@@ -6,7 +6,6 @@ import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import type { Id } from "../../convex/_generated/dataModel";
import { getUserFacingConvexError } from "../lib/convexError";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { VersionChangelog } from "./VersionChangelog";
@@ -39,7 +38,6 @@ function mergeSkillVersions(...groups: Doc<"skillVersions">[][]) {
}
function buildVersionDownloadHref(
convexSiteUrl: string,
skillSlug: string,
ownerHandle: string | null | undefined,
version: string,
@@ -48,7 +46,7 @@ function buildVersionDownloadHref(
const normalizedOwner = ownerHandle?.trim().replace(/^@+/, "");
if (normalizedOwner) params.set("ownerHandle", normalizedOwner);
params.set("version", version);
return `${convexSiteUrl}/api/v1/download?${params.toString()}`;
return `/api/v1/download?${params.toString()}`;
}
export function SkillVersionsPanel({
@@ -62,7 +60,6 @@ export function SkillVersionsPanel({
ownerHandle,
suppressedMessage,
}: SkillVersionsPanelProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const deleteOwnedVersion = useMutation(api.skills.deleteOwnedVersion);
const restoreOwnedVersion = useMutation(api.skills.restoreOwnedVersion);
const {
@@ -222,12 +219,8 @@ export function SkillVersionsPanel({
<>
{!nixPlugin && isAvailable ? (
<a
href={buildVersionDownloadHref(
convexSiteUrl,
skillSlug,
ownerHandle,
version.version,
)}
href={buildVersionDownloadHref(skillSlug, ownerHandle, version.version)}
download={`${skillSlug}-${version.version}.zip`}
className="skill-version-release-download skill-version-release-download-labeled"
aria-label={`Download version v${version.version}`}
>