Files
Jesse Merhi dcbc38999f fix: require multipart package publishes
Require multipart package publishes so public clients cannot provide trusted file or artifact metadata.

Preserve released CLI multipart field names (`files` and `clawpack`), derive stored file and npm-pack artifact metadata from uploaded bytes, and keep large ClawPack tarballs working through ticketed staged uploads.

Tests:
- bunx vitest run convex/uploads.test.ts --reporter=dot
- bunx vitest run convex/httpApi.handlers.test.ts convex/httpApiV1.handlers.test.ts --testNamePattern "package publish|multipart package publish|multipart ClawPack|staged ClawPack|browser session auth|cliUploadUrl|upload tickets" --reporter=dot
- bun run --cwd packages/clawhub test:src -- src/cli/commands/packages.test.ts --testNamePattern "ClawPack tarballs over|stages ClawPack|publishes a ClawPack|cleans generated ClawPack"
- bun run format:check
- bun run lint
- bunx tsc -p packages/schema/tsconfig.json --noEmit --pretty false
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit --pretty false
- bunx tsc --noEmit --pretty false
- bun run ci:unit
- git diff --check origin/main
2026-06-02 09:59:53 +10:00

141 lines
3.9 KiB
TypeScript

/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import { consumePackagePublishUploadTicketInternal } from "./uploads";
type ConsumeArgs = {
uploadTicket: string;
storageId: string;
auth: { kind: "user"; userId: string } | { kind: "github-actions"; publishTokenId: string };
};
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<void>;
};
const consumeHandler = (
consumePackagePublishUploadTicketInternal as unknown as WrappedHandler<ConsumeArgs>
)._handler;
function makeCtx(ticket: Record<string, unknown> | null, storage: Record<string, unknown> | null) {
return {
db: {
get: vi.fn(async () => ticket),
insert: vi.fn(),
normalizeId: vi.fn(),
patch: vi.fn(),
query: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
system: {
get: vi.fn(async () => storage),
query: vi.fn(),
},
},
};
}
describe("package publish upload tickets", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("consumes a fresh upload ticket for the same user", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000);
const ctx = makeCtx(
{
_id: "packagePublishUploadTickets:1",
kind: "user",
userId: "users:1",
createdAt: 1_000,
expiresAt: 10_000,
},
{ _id: "storage:1", _creationTime: 1_500 },
);
await consumeHandler(ctx, {
uploadTicket: "packagePublishUploadTickets:1",
storageId: "storage:1",
auth: { kind: "user", userId: "users:1" },
});
expect(ctx.db.system.get).toHaveBeenCalledWith("_storage", "storage:1");
expect(ctx.db.patch).toHaveBeenCalledWith("packagePublishUploadTickets:1", {
usedAt: 2_000,
storageId: "storage:1",
});
});
it("allows retrying a used upload ticket for the same user and storage id", async () => {
vi.spyOn(Date, "now").mockReturnValue(3_000);
const ctx = makeCtx(
{
_id: "packagePublishUploadTickets:1",
kind: "user",
userId: "users:1",
createdAt: 1_000,
expiresAt: 10_000,
usedAt: 2_000,
storageId: "storage:1",
},
{ _id: "storage:1", _creationTime: 1_500 },
);
await consumeHandler(ctx, {
uploadTicket: "packagePublishUploadTickets:1",
storageId: "storage:1",
auth: { kind: "user", userId: "users:1" },
});
expect(ctx.db.patch).not.toHaveBeenCalled();
});
it("rejects upload tickets from another auth context", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000);
const ctx = makeCtx(
{
_id: "packagePublishUploadTickets:1",
kind: "user",
userId: "users:1",
createdAt: 1_000,
expiresAt: 10_000,
},
{ _id: "storage:1", _creationTime: 1_500 },
);
await expect(
consumeHandler(ctx, {
uploadTicket: "packagePublishUploadTickets:1",
storageId: "storage:1",
auth: { kind: "user", userId: "users:2" },
}),
).rejects.toThrow("Package tarball upload ticket does not match this publish token");
expect(ctx.db.patch).not.toHaveBeenCalled();
});
it("rejects storage created before the upload ticket", async () => {
vi.spyOn(Date, "now").mockReturnValue(2_000);
const ctx = makeCtx(
{
_id: "packagePublishUploadTickets:1",
kind: "github-actions",
publishTokenId: "packagePublishTokens:1",
createdAt: 1_000,
expiresAt: 10_000,
},
{ _id: "storage:1", _creationTime: 999 },
);
await expect(
consumeHandler(ctx, {
uploadTicket: "packagePublishUploadTickets:1",
storageId: "storage:1",
auth: { kind: "github-actions", publishTokenId: "packagePublishTokens:1" },
}),
).rejects.toThrow("Package tarball upload must be created after its upload ticket");
expect(ctx.db.patch).not.toHaveBeenCalled();
});
});