diff --git a/deploy/cloudflare/src/d1-artifact-repository.ts b/deploy/cloudflare/src/d1-artifact-repository.ts index ac696d0f..df4edfc4 100644 --- a/deploy/cloudflare/src/d1-artifact-repository.ts +++ b/deploy/cloudflare/src/d1-artifact-repository.ts @@ -164,6 +164,11 @@ const actionSchema = z.enum([ artifactActionKinds.publish, artifactActionKinds.restore, ]); +const stagedUploadGrantRowSchema = z.object({ + grantDigest: z.string().nullable(), + principalId: z.string(), + projectId: z.string(), +}); const projectRowSchema = z.object({ archivedAt: z.string().nullable(), createdAt: z.string(), @@ -2116,8 +2121,9 @@ export function createD1ArtifactRepository( database.prepare(` INSERT INTO staged_uploads ( id, project_id, principal_id, status, manifest_digest, entry_path, - routing_mode, created_at, expires_at, committed_version_id - ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, NULL) + routing_mode, created_at, expires_at, committed_version_id, + grant_digest + ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, NULL, ?) `).bind( command.id, command.projectId, @@ -2127,6 +2133,7 @@ export function createD1ArtifactRepository( command.manifest.routingMode, command.createdAt, command.expiresAt, + command.grantDigest, ), // `uploaded_at` stays out of the column list so it defaults to NULL: // every bound column costs part of the per-statement budget. @@ -2153,6 +2160,14 @@ export function createD1ArtifactRepository( return upload; }, findStagedUpload: readStagedUploadOrNull, + findStagedUploadGrant: async (uploadId: string) => { + const row = await database.prepare(` + SELECT grant_digest AS grantDigest, principal_id AS principalId, + project_id AS projectId + FROM staged_uploads WHERE id = ? + `).bind(uploadId).first(); + return stagedUploadGrantRowSchema.nullable().parse(row ?? null); + }, markStagedFileUploaded: async ( projectId, uploadId, diff --git a/deploy/cloudflare/src/d1-migrations.ts b/deploy/cloudflare/src/d1-migrations.ts index a1c2cce1..ba85c760 100644 --- a/deploy/cloudflare/src/d1-migrations.ts +++ b/deploy/cloudflare/src/d1-migrations.ts @@ -8,7 +8,7 @@ import {defaultGitHistoryMaximumCopiedFiles} from "../../../src/git-history/git-history-capability.js"; /** D1 schema revision required by the Cloudflare runtime. */ -export const requiredD1SchemaVersion = 9; +export const requiredD1SchemaVersion = 10; /** SQL literal list of every action kind the ledger accepts. */ const actionKindList = [ @@ -115,7 +115,8 @@ const schemaSql = ` routing_mode TEXT NOT NULL CHECK (routing_mode IN ('static', 'spa')), created_at TEXT NOT NULL, expires_at TEXT NOT NULL, - committed_version_id TEXT REFERENCES versions(id) + committed_version_id TEXT REFERENCES versions(id), + grant_digest TEXT ); CREATE TABLE IF NOT EXISTS staged_upload_files ( @@ -617,6 +618,7 @@ export async function migrateD1( await addGitHistoryMirrorColumnsIfMissing(database); await widenRegisteredAgentsIfNeeded(database); await addArtifactSearchNameIfMissing(database); + await addStagedUploadGrantIfMissing(database); } await database.batch([ database.prepare(` @@ -638,6 +640,16 @@ export async function migrateD1( ]); } +/** Give staged uploads a nullable grant digest; older uploads keep none. */ +async function addStagedUploadGrantIfMissing(database: D1Database): Promise { + const columns = await database.prepare("PRAGMA table_info(staged_uploads)") + .all<{name: string}>(); + if (columns.results.some((column) => column.name === "grant_digest")) return; + await database.prepare( + "ALTER TABLE staged_uploads ADD COLUMN grant_digest TEXT", + ).run(); +} + async function addArtifactSearchNameIfMissing( database: D1Database, ): Promise { diff --git a/deploy/cloudflare/tests/agent-dispatch-runtime.test.ts b/deploy/cloudflare/tests/agent-dispatch-runtime.test.ts index b73fe80f..fe5d3d59 100644 --- a/deploy/cloudflare/tests/agent-dispatch-runtime.test.ts +++ b/deploy/cloudflare/tests/agent-dispatch-runtime.test.ts @@ -502,7 +502,7 @@ describe("Cloudflare D1 agent dispatch", () => { try { expect(schemaVersionRowSchema.parse(upgraded.prepare( "SELECT version FROM artifact_server_schema WHERE component = 'runtime'", - ).get()).version).toBe(9); + ).get()).version).toBe(10); expect(z.array(tableColumnRowSchema) .parse(upgraded.prepare("PRAGMA table_info(comment_threads)").all()) .map(({name}) => name)).toContain("dispatch_id"); diff --git a/deploy/cloudflare/tests/comment-runtime.test.ts b/deploy/cloudflare/tests/comment-runtime.test.ts index 84c7926c..1344b146 100644 --- a/deploy/cloudflare/tests/comment-runtime.test.ts +++ b/deploy/cloudflare/tests/comment-runtime.test.ts @@ -376,6 +376,44 @@ describe("Cloudflare D1 comments", () => { expect(afterRestart.items[0]?.state).toBe("resolved"); }, 120_000); + it("adds the staged upload grant column to a revision 9 database", async () => { + const grantDirectory = await mkdtemp( + join(tmpdir(), "artifact-server-cloudflare-grant-"), + ); + let grantWorker = await startWorker(grantDirectory); + try { + expect((await authenticatedFetch(grantWorker, `${origin}/api/v1/artifacts`)).status) + .toBe(200); + await grantWorker.stop(); + const d1File = await findD1DatabaseFile(grantDirectory); + const database = new DatabaseSync(d1File); + try { + database.exec(` + ALTER TABLE staged_uploads DROP COLUMN grant_digest; + UPDATE artifact_server_schema SET version = 9 WHERE component = 'runtime'; + `); + } finally { + database.close(); + } + + grantWorker = await startWorker(grantDirectory); + expect((await authenticatedFetch(grantWorker, `${origin}/api/v1/artifacts`)).status) + .toBe(200); + const upgraded = new DatabaseSync(d1File, {readOnly: true}); + try { + const columns = z.array(z.object({name: z.string()})).parse( + upgraded.prepare("PRAGMA table_info(staged_uploads)").all(), + ); + expect(columns.map(({name}) => name)).toContain("grant_digest"); + } finally { + upgraded.close(); + } + } finally { + await grantWorker.stop(); + await rm(grantDirectory, {force: true, recursive: true}); + } + }, 120_000); + it("upgrades a version 2 database in place and then accepts comments", async () => { const upgradeDirectory = await mkdtemp( join(tmpdir(), "artifact-server-cloudflare-upgrade-"), @@ -497,7 +535,7 @@ describe("Cloudflare D1 comments", () => { try { expect(schemaVersionRowSchema.parse(upgraded.prepare( "SELECT version FROM artifact_server_schema WHERE component = 'runtime'", - ).get()).version).toBe(9); + ).get()).version).toBe(10); const columns = z.array(tableColumnRowSchema) .parse(upgraded.prepare("PRAGMA table_info(login_attempts)").all()) .map(({name}) => name); diff --git a/deploy/cloudflare/tests/worker-runtime.test.ts b/deploy/cloudflare/tests/worker-runtime.test.ts index 095a01fd..3e83c824 100644 --- a/deploy/cloudflare/tests/worker-runtime.test.ts +++ b/deploy/cloudflare/tests/worker-runtime.test.ts @@ -62,6 +62,45 @@ afterAll(async () => { }); describe("Cloudflare Worker runtime", () => { + it("publishes through MCP with an upload grant on D1", async () => { + const bytes = new TextEncoder().encode("

D1 grant

"); + const plan = await mcpTool("artifact_create_upload", { + entryPath: "index.html", + files: [{ + mediaType: "text/html", + path: "index.html", + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + }], + }, z.object({ + files: z.array(z.object({uploadUrl: z.string()})).length(1), + uploadGrant: z.object({token: z.string().startsWith("as_upg_")}), + uploadId: z.string(), + })); + const file = plan.files[0]; + if (file === undefined) throw new Error("The upload plan is empty."); + const planned = new URL(file.uploadUrl); + const uploadUrl = `${origin}${planned.pathname}${planned.search}`; + const putWithGrant = () => worker.fetch(uploadUrl, { + body: bytes, + headers: {Authorization: `Bearer ${plan.uploadGrant.token}`}, + method: "PUT", + }); + + expect((await putWithGrant()).status).toBe(200); + const committed = await mcpTool("artifact_commit_upload", { + idempotencyKey: "d1-upload-grant-commit", + target: { + accessSetting: "account_required", + kind: "new_artifact", + name: "D1 grant", + }, + uploadId: plan.uploadId, + }, z.object({links: z.object({review: z.url()}).loose()}).loose()); + expect(new URL(committed.links.review).pathname).toBe("/review"); + expect((await putWithGrant()).status).toBe(409); + }, 60_000); + it("serves the management application only from its configured origin", async () => { const shell = await worker.fetch(`${origin}/review?project=prj_default`); const shellHtml = await shell.text(); @@ -322,3 +361,60 @@ function authenticatedJsonHeaders() { "Content-Type": "application/json", }; } + +interface McpParameters { + readonly [key: string]: McpParameterValue; +} + +type McpParameterValue = + | boolean + | number + | string + | null + | readonly McpParameterValue[] + | McpParameters; + +async function mcpTool( + name: string, + parameters: McpParameters, + schema: z.ZodType, +): Promise { + const protocolVersion = "2026-07-28"; + const response = await worker.fetch(`${origin}/mcp`, { + body: JSON.stringify({ + id: crypto.randomUUID(), + jsonrpc: "2.0", + method: "tools/call", + params: { + _meta: { + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + name: "artifact-server-cloudflare-test", + version: "1", + }, + "io.modelcontextprotocol/protocolVersion": protocolVersion, + }, + arguments: parameters, + name, + }, + }), + headers: { + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": "tools/call", + "Mcp-Name": name, + }, + method: "POST", + }); + expect(response.status).toBe(200); + const result = z.object({ + result: z.object({ + isError: z.boolean().optional(), + structuredContent: z.unknown(), + }).loose(), + }).parse(await response.json()).result; + expect(result.isError).not.toBe(true); + return schema.parse(result.structuredContent); +} diff --git a/project/spec/artifact-server-mcp-baseline.md b/project/spec/artifact-server-mcp-baseline.md index b592c789..5f5ce6cb 100644 --- a/project/spec/artifact-server-mcp-baseline.md +++ b/project/spec/artifact-server-mcp-baseline.md @@ -8,7 +8,7 @@ Artifact Server exposes one MCP control endpoint at `POST /mcp`. It uses the same artifact, version, permission, and comparison services as the browser application and normal HTTP API. MCP does not implement a second copy of product logic. -Large website and file bytes do not travel inside MCP messages. MCP starts an upload, returns short-lived upload instructions, and later commits the uploaded files as an immutable version. +Large website and file bytes do not travel inside MCP messages. MCP starts an upload, returns short-lived upload instructions with an upload grant that authorizes only that upload's file uploads, and later commits the uploaded files as an immutable version. The deployment chooses its authorization mode: @@ -262,7 +262,7 @@ projects. Service principals require explicit capabilities. - Map the OAuth subject to one local Artifact Server user admitted to this installation. - Do not accept a user merely because an authorization provider authenticated them. Admission is controlled by the installation administrator or configured company login. - Verify installation membership or an explicit service capability on every upload, publish, access-setting change, restore, and delete. -- Bind upload and operation handles to the verified principal and installation. +- Bind upload and operation handles to the verified principal and installation. An upload grant authorizes only the file uploads of the upload that issued it, until that upload is committed or expires. - API keys bind to a local user or service principal, installation, capabilities, expiry, and revoke state. - MCP handlers call the same authorization layer as the browser and normal HTTP API. @@ -323,7 +323,7 @@ Start with a small, explicit tool surface: | `artifact_list` | Find artifacts the current principal may access. | | `artifact_get` | Read artifact metadata, the current immutable version, its complete manifest, and its exact full-screen Review link. | | `artifact_open` | Return the exact full-screen Review link and raw content link for the selected artifact and exact version when requested. The client opens them on the user's computer and prefers Review for human handoff. | -| `artifact_create_upload` | Create an expiring upload handle and direct-upload plan. | +| `artifact_create_upload` | Create an expiring upload handle, a direct-upload plan, and an upload grant for its file uploads. | | `artifact_commit_upload` | Verify the manifest and publish an immutable version. Return the exact-version review URL as the primary human handoff, plus stable and raw links. | | `artifact_set_visibility` | Change between account-required and public-link access when permitted. A public link opens only the current version. Warn that public copies cannot be recalled and perform the configured CDN purge when returning to account-required. | | `artifact_set_tags` | Replace the artifact's normalized tag set without changing its saved files. | diff --git a/project/spec/conformance.yml b/project/spec/conformance.yml index 7e009b1f..4dfee980 100644 --- a/project/spec/conformance.yml +++ b/project/spec/conformance.yml @@ -511,7 +511,7 @@ requirements: source: {file: artifact-server-product-spec.html, anchor: architecture} acceptance: behavior: {id: PUB-001-B, description: "Create an upload and verify its principal, installation, unique staging location, and expiration."} - failure: {id: PUB-001-F, description: "Another principal or installation cannot read, append, commit, or renew the staged upload."} + failure: {id: PUB-001-F, description: "Another principal or installation cannot read, append, commit, or renew the staged upload; only a holder of that upload's grant may append its declared files (PUB-014)."} deployments: *all status: behavior_verified depends_on: [AUTH-008] @@ -749,6 +749,19 @@ requirements: run: project/evidence/browser.json recorded_at: "2026-08-25T17:52:50.473Z" + - id: PUB-014 + kind: behavior + behavior: An MCP upload plan carries a short-lived upload grant that authorizes only that upload's file uploads, so a client without an Artifact Server API key can publish through MCP. + owner: publishing + source: {file: decisions/0031-upload-grants-for-mcp-uploads.md, anchor: decision} + acceptance: + behavior: {id: PUB-014-B, description: "An MCP client whose credential /api/ refuses uploads every planned file with the returned grant, in parallel, and commits through MCP; the grant never appears in logs."} + failure: {id: PUB-014-F, description: "The grant is refused on another upload, another route, another method, a changed path, the wrong project, and when altered; after commit and after expiry it is refused with the upload's own error; the MCP credential itself is still refused on /api/."} + deployments: *all + status: implementing + proof_gap: PUB-014-B and PUB-014-F run against the local SQLite server; Postgres and D1 runtime tests cover grant storage and a D1 publish through MCP. No deployment-specific evidence is recorded. + depends_on: [PUB-001, PUB-002, MCP-013] + evidence: [] - id: CLI-001 kind: security behavior: The Artifact Server CLI keeps profiles by exact server origin and account; remote interactive login uses browser authorization and operating-system credential storage, local mode uses private user-only state without visible secrets, and CI uses scoped service credentials from its secret manager. diff --git a/project/spec/decisions/0031-upload-grants-for-mcp-uploads.md b/project/spec/decisions/0031-upload-grants-for-mcp-uploads.md new file mode 100644 index 00000000..3f3d6fdf --- /dev/null +++ b/project/spec/decisions/0031-upload-grants-for-mcp-uploads.md @@ -0,0 +1,117 @@ +# 0031: Upload grants for MCP file uploads + +**Status:** Proposed +**Date:** October 12, 2026 + +Would amend PUB-001-F and the MCP upload wording. API keys, browser sessions, +the HTTP upload plan, and the commit step keep their behavior. + +## Decision + +`artifact_create_upload` returns an **upload grant** with its upload plan: a +short-lived bearer credential that authorizes only the file uploads of that +one upload. The agent sends it as `Authorization: Bearer` on each file `PUT` +and then commits through MCP as before. + +| Request with the grant | Result | +| --- | --- | +| `PUT /api/v1/uploads/{id}/files/{token}` of its own upload, while open | the declared bytes are stored | +| the same route after commit or expiry | the upload's own `UPLOAD_CLOSED` or `UPLOAD_EXPIRED` error | +| any other upload, route, method, or project | `401 AUTHENTICATION_REQUIRED` | + +## Why + +The upload plan told the agent to send the file bytes "using the same bearer +credential" as MCP. The agent never holds that credential. An MCP client +keeps its OAuth tokens to itself, and the bundled skill forbids exposing the +MCP bearer credential to the model. The upload routes also accept only +Artifact Server API keys: 0028 keeps OAuth off `/api/` on purpose. So an MCP +client signed in through OAuth could create an upload but never send its +files, and publishing through MCP worked only when the model happened to know +an API key. + +An upload grant gives the agent a credential it may hold: narrow, short-lived, +and useless for anything but the bytes the upload already declared. + +## Recorded decisions + +### One grant per upload + +The grant is `as_upg_` followed by 32 random bytes in base64url. Every file of +an upload shares it. A grant per file would add nothing, because each file is +already bound to its declared size and SHA-256 fingerprint. The server stores +only the SHA-256 digest of the grant, in a nullable column on the staged +upload. No new server secret is needed. + +### In the header, not the URL + +The grant travels in the `Authorization` header. Proxies and access logs +record URLs, so the upload URL stays free of credentials, and clients keep the +request shape they already use. + +### One route, one upload + +The `/api/` authentication step accepts a grant only for `PUT` on the file +route. Anywhere else it answers `401 AUTHENTICATION_REQUIRED`, as for an +invalid API key, so no handler that expects a principal ever sees a grant. The file route compares the grant with +the stored digest in constant time and checks the `projectId` in the URL +against the upload's project. A wrong grant, an unknown upload, the wrong +project, and an upload without a grant all answer the same 401, whose status +and body do not say whether the upload exists. + +### Checked when issued and at commit + +Permission is checked when the grant is issued, because `artifact_create_upload` +runs the full publication check, and again at commit, because +`artifact_commit_upload` runs it on the live MCP credential. The grant itself +lets only the declared bytes in, like a presigned URL. A member deactivated +during an upload can still store those bytes until the upload expires, at most +one hour later, but cannot publish them. Expiry cleanup removes them. + +### Lifetime follows the upload + +The grant expires with its upload and stops working once the upload is +committed. There is no separate lifetime setting. + +### Only MCP plans carry a grant + +`POST /api/v1/uploads` issues no grant: HTTP and CLI callers already hold a +credential that `/api/` accepts. An API key still authorizes the file `PUT` +for clients that have one. Uploads created before an upgrade have no grant and +keep working with API keys until they expire. + +### The plan names the grant + +Each planned file's `authorization.credential` changes from +`reuse_the_mcp_bearer_credential` to `upload_grant`, and the plan gains +`uploadGrant: {scheme, token, expiresAt}` once per upload. The old value named +a credential the agent is never given, so the change fixes a contract that +could not be followed. + +## Requirements + +- **PUB-014:** an MCP upload plan carries an upload grant that authorizes only + that upload's file uploads, so a client without an Artifact Server API key + can publish through MCP. +- **PUB-001-F** gains one exception: a holder of the upload's grant may append + its declared files. + +## Rejected alternatives + +### Accept the MCP OAuth token on the upload route + +It reuses a token issued for the MCP resource on a different resource, which +breaks the audience binding 0028 relies on. It also does not help the agent, +which never holds that token. + +### OAuth on the whole HTTP API + +0028 excludes it. MCP clients would still need a second token for a second +resource, and the agent would still not hold it. + +### A signed, stateless grant + +An HMAC over the upload id and expiry needs no column but needs a new server +secret that every deployment must generate, store, and rotate. A stored digest +follows the pattern API keys, browser sessions, and content bootstraps already +use. diff --git a/skills/artifact-server/SKILL.md b/skills/artifact-server/SKILL.md index 096995cb..456e3b50 100644 --- a/skills/artifact-server/SKILL.md +++ b/skills/artifact-server/SKILL.md @@ -33,7 +33,9 @@ steps separate and state which installation is affected before changing it. - Never replace a real file with inline HTML, base64, invented bytes, or a local path sent to a remote MCP server. - Never read, print, request in chat, or write bearer tokens, API keys, cookies, - or refresh tokens. + or refresh tokens. The one exception is the `as_upg_` upload grant that + `artifact_create_upload` returns: use it only in the `Authorization` header of + that upload's file PUTs, and never print it, repeat it in chat, or store it. - Keep public sharing off unless the user explicitly requests it. - Verify success from structured CLI, MCP, HTTP, or deployment health results. Never infer success from a command that merely started. diff --git a/skills/artifact-server/references/artifact-operations.md b/skills/artifact-server/references/artifact-operations.md index 7c481a5a..56488e9f 100644 --- a/skills/artifact-server/references/artifact-operations.md +++ b/skills/artifact-server/references/artifact-operations.md @@ -59,7 +59,7 @@ Project creation, rename, archive, or unarchive are ordinary product actions but Do not use MCP upload tools when the files exist on the user's machine and the CLI is available. The CLI provides the one-step authenticated experience. -Use `artifact_create_upload` and `artifact_commit_upload` only when the current agent environment can actually read the file bytes and execute the returned authenticated HTTP uploads. Call `artifact_capabilities` first. Never place file bytes, base64, or a local path in MCP arguments, and never expose the MCP bearer credential to the model or output. +Use `artifact_create_upload` and `artifact_commit_upload` only when the current agent environment can actually read the file bytes and execute the returned HTTP uploads. Call `artifact_capabilities` first. Upload each file with `Authorization: Bearer ` from the upload plan; the grant works only for that upload and expires with it. Never place file bytes, base64, or a local path in MCP arguments, and never expose the MCP bearer credential to the model or output. ## Final result diff --git a/src/application/staged-upload.ts b/src/application/staged-upload.ts index dc8db558..fc8d4886 100644 --- a/src/application/staged-upload.ts +++ b/src/application/staged-upload.ts @@ -1,7 +1,8 @@ -import { Context, DateTime, Effect, Layer } from "effect"; +import { Context, DateTime, Effect, Layer, type Redacted } from "effect"; import { type ArtifactRepositoryFailure, + AuthenticationRequired, type AuthorizationDenied, StagingStorageFailure, type ProjectArchived, @@ -26,8 +27,10 @@ import type { IdGenerator, OpenedStagedFile, StagedFileWrite, + StagedUploadGrantRecord, StoredBlob, } from "../core/ports.js"; +import { uploadGrantDigestsEqual } from "../core/upload-grant.js"; import type { DeclaredManifestFile } from "../manifest/create-manifest.js"; import { type PublicationFileSource, @@ -65,6 +68,30 @@ export interface UploadStagedFileCommand { readonly uploadId: string; } +/** Input for streaming one file into a staged upload with its upload grant. */ +export interface UploadStagedFileWithGrantCommand { + readonly body: ReadableStream; + readonly grant: Redacted.Redacted; + readonly projectId: string | null; + readonly storageToken: string; + readonly uploadId: string; +} + +/** A staged upload together with the grant that may append its files. */ +export interface CreatedStagedUploadWithGrant { + readonly grant: Redacted.Redacted; + readonly upload: StagedUpload; +} + +/** Issues upload grants and digests presented ones; only digests are stored. */ +export interface UploadGrantSecretProvider { + readonly digest: (token: Redacted.Redacted) => string; + readonly issue: () => { + readonly digest: string; + readonly token: Redacted.Redacted; + }; +} + /** Publication target selected when committing a staged upload. */ export type CommitStagedUploadTarget = | { @@ -98,6 +125,9 @@ export interface StagedUploadRepositoryPort { uploadId: string, principalId: string, ): Effect.Effect; + findStagedUploadGrant( + uploadId: string, + ): Effect.Effect; markStagedFileUploaded( projectId: string, uploadId: string, @@ -142,12 +172,14 @@ export interface StagingStoragePort { export interface StagedUploadDependencies { readonly clock: ApplicationClock; readonly ids: IdGenerator; + readonly secrets: UploadGrantSecretProvider; readonly staging: StagingStoragePort; readonly uploads: StagedUploadRepositoryPort; } /** Expected failures produced by the staged upload application service. */ export type StagedUploadFailure = + | AuthenticationRequired | ManifestFailure | UploadNotFound | UploadClosed @@ -168,9 +200,15 @@ interface StagedUploadOperations { readonly createUpload: ( command: CreateStagedUploadCommand, ) => Effect.Effect; + readonly createUploadWithGrant: ( + command: CreateStagedUploadCommand, + ) => Effect.Effect; readonly uploadFile: ( command: UploadStagedFileCommand, ) => Effect.Effect; + readonly uploadFileWithGrant: ( + command: UploadStagedFileWithGrantCommand, + ) => Effect.Effect; } /** Owns the principal-bound upload lifecycle before immutable publication. */ @@ -225,9 +263,10 @@ function makeStagedUploadService( }, ); - const createUpload = Effect.fn("StagedUploadService.createUpload")( + const create = Effect.fn("StagedUploadService.create")( function*( command: CreateStagedUploadCommand, + grantDigest: string | null, ): Effect.fn.Return { yield* authorization.requirePublicationPreparation(command.principal); const project = yield* projects.resolveActiveProject({ @@ -249,6 +288,7 @@ function makeStagedUploadService( entry, storageToken: dependencies.ids.stagedFileToken(), })), + grantDigest, id: dependencies.ids.uploadId(), manifest, principalId: command.principal.id, @@ -257,24 +297,36 @@ function makeStagedUploadService( }, ); - const uploadFile = Effect.fn("StagedUploadService.uploadFile")( + const createUpload = Effect.fn("StagedUploadService.createUpload")( function*( - command: UploadStagedFileCommand, + command: CreateStagedUploadCommand, + ): Effect.fn.Return { + return yield* create(command, null); + }, + ); + + const createUploadWithGrant = Effect.fn( + "StagedUploadService.createUploadWithGrant", + )(function*( + command: CreateStagedUploadCommand, + ): Effect.fn.Return { + const issued = dependencies.secrets.issue(); + const upload = yield* create(command, issued.digest); + return {grant: issued.token, upload}; + }); + + const storeFile = Effect.fn("StagedUploadService.storeFile")( + function*( + upload: StagedUpload, + principalId: string, + projectId: string, + storageToken: string, + body: ReadableStream, ): Effect.fn.Return { - yield* authorization.requirePublicationPreparation(command.principal); - const project = yield* projects.resolveActiveProject({ - principal: command.principal, - projectId: command.projectId ?? null, - }); - const upload = yield* requiredUpload( - project.id, - command.uploadId, - command.principal.id, - ); const uploadStartedAt = yield* dependencies.clock.now; yield* ensureUploadAcceptsFiles(upload, uploadStartedAt); const file = upload.files.find( - (candidate) => candidate.storageToken === command.storageToken, + (candidate) => candidate.storageToken === storageToken, ); if (file === undefined) { return yield* new UploadFileNotFound({ @@ -284,7 +336,7 @@ function makeStagedUploadService( const signal = abortSignalUntil(upload.expiresAt, uploadStartedAt); yield* dependencies.staging.put({ - body: command.body, + body, sha256: file.entry.sha256, signal, size: file.entry.size, @@ -295,15 +347,76 @@ function makeStagedUploadService( : error))); const uploadedAt = DateTime.formatIso(yield* dependencies.clock.now); return yield* dependencies.uploads.markStagedFileUploaded( - project.id, + projectId, upload.id, - command.principal.id, + principalId, file.storageToken, uploadedAt, ); }, ); + const uploadFile = Effect.fn("StagedUploadService.uploadFile")( + function*( + command: UploadStagedFileCommand, + ): Effect.fn.Return { + yield* authorization.requirePublicationPreparation(command.principal); + const project = yield* projects.resolveActiveProject({ + principal: command.principal, + projectId: command.projectId ?? null, + }); + const upload = yield* requiredUpload( + project.id, + command.uploadId, + command.principal.id, + ); + return yield* storeFile( + upload, + command.principal.id, + project.id, + command.storageToken, + command.body, + ); + }, + ); + + const uploadFileWithGrant = Effect.fn( + "StagedUploadService.uploadFileWithGrant", + )(function*( + command: UploadStagedFileWithGrantCommand, + ): Effect.fn.Return { + // Permission was checked when the grant was issued and is checked again + // at commit; the grant itself only lets the declared bytes in. + const record = yield* dependencies.uploads.findStagedUploadGrant( + command.uploadId, + ); + if ( + record === null + || record.grantDigest === null + || !uploadGrantDigestsEqual( + dependencies.secrets.digest(command.grant), + record.grantDigest, + ) + || (command.projectId !== null && command.projectId !== record.projectId) + ) { + return yield* new AuthenticationRequired({ + message: "A valid upload grant is required.", + }); + } + const upload = yield* requiredUpload( + record.projectId, + command.uploadId, + record.principalId, + ); + return yield* storeFile( + upload, + record.principalId, + record.projectId, + command.storageToken, + command.body, + ); + }); + const publicationSource = ( upload: StagedUpload, file: StagedUpload["files"][number], @@ -419,7 +532,13 @@ function makeStagedUploadService( }, ); - return StagedUploadService.of({commitUpload, createUpload, uploadFile}); + return StagedUploadService.of({ + commitUpload, + createUpload, + createUploadWithGrant, + uploadFile, + uploadFileWithGrant, + }); } function abortSignalUntil(expiresAt: string, now: DateTime.Utc): AbortSignal { diff --git a/src/core/errors.ts b/src/core/errors.ts index 389f4c7f..dfb800fb 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -388,6 +388,7 @@ export class ArtifactRepositoryFailure extends Schema.TaggedError; } +/** Who owns a staged upload and which grant, if any, may append its files. */ +export interface StagedUploadGrantRecord { + readonly grantDigest: string | null; + readonly principalId: string; + readonly projectId: string; +} + export interface StagedUploadRepository { createStagedUpload(command: CreateStagedUpload): Promise; findStagedUpload( @@ -693,6 +702,7 @@ export interface StagedUploadRepository { uploadId: string, principalId: string, ): Promise; + findStagedUploadGrant(uploadId: string): Promise; markStagedFileUploaded( projectId: string, uploadId: string, diff --git a/src/core/upload-grant.ts b/src/core/upload-grant.ts new file mode 100644 index 00000000..265f421c --- /dev/null +++ b/src/core/upload-grant.ts @@ -0,0 +1,18 @@ +import {timingSafeEqual} from "node:crypto"; + +/** Prefix that marks a bearer as a staged upload grant, never an API key. */ +export const uploadGrantPrefix = "as_upg_"; + +/** Report whether a bearer is shaped like an upload grant. */ +export function isUploadGrantToken(token: string): boolean { + return token.startsWith(uploadGrantPrefix); +} + +/** Compare two hex SHA-256 digests without leaking where they differ. */ +export function uploadGrantDigestsEqual(presented: string, stored: string): boolean { + const left = Buffer.from(presented, "hex"); + const right = Buffer.from(stored, "hex"); + return left.byteLength === 32 + && right.byteLength === 32 + && timingSafeEqual(left, right); +} diff --git a/src/http/create-http-app.ts b/src/http/create-http-app.ts index 45aa95cc..600c4aca 100644 --- a/src/http/create-http-app.ts +++ b/src/http/create-http-app.ts @@ -116,6 +116,7 @@ import { type VersionRecord, } from "../core/model.js"; import type { BlobStore } from "../core/ports.js"; +import { isUploadGrantToken } from "../core/upload-grant.js"; import { disabledGitHistoryCapability, fixedGitHistoryCapabilityReader, @@ -206,6 +207,9 @@ const contentTokenSchema = z .min(16) .max(100) .regex(/^[A-Za-z0-9_-]+$/u); +/** The only route an upload grant opens: one file of one staged upload. */ +const uploadFileRoutePattern = /^\/api\/v1\/uploads\/[^/]+\/files\/[^/]+$/u; + const bearerSchema = z .string() .regex(/^Bearer [A-Za-z0-9._~-]+$/u) @@ -465,6 +469,7 @@ interface HttpEnvironment { readonly requestSpan: Tracer.Span; readonly sessionCsrfDigest: string | null; readonly sessionToken: string | null; + readonly uploadGrant: string | null; }; } @@ -806,6 +811,22 @@ export function createHttpApp( message: "A valid Artifact Server API key is required.", }); } + if (isUploadGrantToken(parsed.data)) { + if ( + context.req.method !== "PUT" + || !uploadFileRoutePattern.test(context.req.path) + ) { + throw new AuthenticationRequired({ + message: "A valid upload grant is required.", + }); + } + // No principal: the file route checks the grant against its upload. + context.set("authenticationMethod", "bearer"); + context.set("sessionCsrfDigest", null); + context.set("sessionToken", null); + context.set("uploadGrant", parsed.data); + return next(); + } const principal = await runHttpApplicationEffect( context, dependencies, @@ -819,6 +840,7 @@ export function createHttpApp( context.set("principal", principal); context.set("sessionCsrfDigest", null); context.set("sessionToken", null); + context.set("uploadGrant", null); return next(); } @@ -844,6 +866,7 @@ export function createHttpApp( context.set("principal", authenticated.principal); context.set("sessionCsrfDigest", authenticated.csrfDigest); context.set("sessionToken", parsedSession.data); + context.set("uploadGrant", null); if (isUnsafeMethod(context.req.method)) { requireBrowserMutationSecurity(context, dependencies); } @@ -2191,17 +2214,26 @@ export function createHttpApp( app.put("/api/v1/uploads/:uploadId/files/:storageToken", async (context) => { const body = context.req.raw.body ?? emptyByteStream(); + const grant = context.get("uploadGrant"); const upload = await runHttpApplicationEffect( context, dependencies, StagedUploadService.use((stagedUploads) => - stagedUploads.uploadFile({ - body, - principal: context.get("principal"), - projectId: requestedProjectId(context), - storageToken: context.req.param("storageToken"), - uploadId: context.req.param("uploadId"), - }) + grant === null + ? stagedUploads.uploadFile({ + body, + principal: context.get("principal"), + projectId: requestedProjectId(context), + storageToken: context.req.param("storageToken"), + uploadId: context.req.param("uploadId"), + }) + : stagedUploads.uploadFileWithGrant({ + body, + grant: Redacted.make(grant, {label: "upload-grant"}), + projectId: requestedProjectId(context), + storageToken: context.req.param("storageToken"), + uploadId: context.req.param("uploadId"), + }) ), ); const file = upload.files.find( diff --git a/src/local/create-local-application-layer.ts b/src/local/create-local-application-layer.ts index 3e390758..fc186406 100644 --- a/src/local/create-local-application-layer.ts +++ b/src/local/create-local-application-layer.ts @@ -127,6 +127,7 @@ import type { import type {IdentityRepository} from "../core/identity-ports.js"; import type { ManifestEntry } from "../core/model.js"; import {randomBase64Url} from "../core/random.js"; +import {uploadGrantPrefix} from "../core/upload-grant.js"; import { FileVerificationError } from "../storage/verified-file.js"; import {defaultStagingCleanupPolicy} from "../lifecycle/staging-cleanup.js"; @@ -282,6 +283,17 @@ export function createApplicationLayer( const stagedDependencies: StagedUploadDependencies = { clock, ids: adapters.ids, + secrets: { + digest: (token) => + createHash("sha256").update(Redacted.value(token)).digest("hex"), + issue: () => { + const token = `${uploadGrantPrefix}${randomBase64Url(32)}`; + return { + digest: createHash("sha256").update(token).digest("hex"), + token: Redacted.make(token, {label: "upload-grant"}), + }; + }, + }, staging: { open: (uploadId, storageToken) => Effect.tryPromise({ @@ -328,6 +340,11 @@ export function createApplicationLayer( ), catch: (cause) => repositoryFailure("findStagedUpload", cause), }), + findStagedUploadGrant: (uploadId) => + Effect.tryPromise({ + try: () => adapters.repository.findStagedUploadGrant(uploadId), + catch: (cause) => repositoryFailure("findStagedUploadGrant", cause), + }), listExpiredStagedUploads: (expiredBefore, limit) => Effect.tryPromise({ try: () => adapters.repository.listExpiredStagedUploads( diff --git a/src/mcp/artifact-mcp-server.ts b/src/mcp/artifact-mcp-server.ts index d76a0d4d..2468f7eb 100644 --- a/src/mcp/artifact-mcp-server.ts +++ b/src/mcp/artifact-mcp-server.ts @@ -1190,7 +1190,7 @@ export function createArtifactMcpServer( { title: "Begin a file upload", description: - "Begin publishing actual files. Supply relative paths, byte sizes, SHA-256 fingerprints, media types, and the entry file. Upload each file to its returned URL, then call artifact_commit_upload. Do not send file bytes through MCP.", + "Begin publishing actual files. Supply relative paths, byte sizes, SHA-256 fingerprints, media types, and the entry file. PUT each file's exact bytes to its returned uploadUrl with the header Authorization: Bearer , then call artifact_commit_upload. The upload grant works only for this upload's files and expires with it; never print it or repeat it in chat. Do not send file bytes through MCP.", inputSchema: z.object({ entryPath: z.string().min(1).max(1_024), files: z.array(declaredFileSchema).min(1).max(maximumDeclaredFiles), @@ -1205,7 +1205,7 @@ export function createArtifactMcpServer( expiresAt: z.string(), files: z.array(z.object({ authorization: z.object({ - credential: z.literal("reuse_the_mcp_bearer_credential"), + credential: z.literal("upload_grant"), scheme: z.literal("Bearer"), }).strict(), method: z.literal("PUT"), @@ -1215,15 +1215,20 @@ export function createArtifactMcpServer( }).strict()), manifestDigest: z.string(), projectId: z.string(), + uploadGrant: z.object({ + expiresAt: z.string(), + scheme: z.literal("Bearer"), + token: z.string(), + }).strict(), uploadId: z.string(), }).strict(), annotations: additiveWriteAnnotations, }, async ({entryPath, files, projectId, routingMode}) => toolResult(async () => { - const upload = await runMcpApplicationEffect( + const created = await runMcpApplicationEffect( dependencies, StagedUploadService.use((uploads) => - uploads.createUpload({ + uploads.createUploadWithGrant({ entryPath, files, principal: identity.principal, @@ -1232,7 +1237,7 @@ export function createArtifactMcpServer( }) ), ); - return uploadPlan(applicationUrl, upload); + return uploadPlan(applicationUrl, created.upload, created.grant); }), ); @@ -2324,7 +2329,7 @@ function agentInstructions(mode: "local" | "remote"): string { "Artifact Server stores actual files as immutable versions. It does not accept inline HTML, CSS, JavaScript, base64, or invented file contents through MCP.", "Start with artifact_capabilities when you do not know this installation's limits.", "Artifacts belong to projects. Omit projectId only when the installation has one active project; otherwise call project_list and choose explicitly.", - "For publishing, inspect the selected file or finished directory on the client, compute each relative path, byte length, media type, and SHA-256 fingerprint, call artifact_create_upload, PUT the exact bytes to every returned uploadUrl using the same bearer credential, then call artifact_commit_upload.", + "For publishing, inspect the selected file or finished directory on the client, compute each relative path, byte length, media type, and SHA-256 fingerprint, call artifact_create_upload, PUT the exact bytes to every returned uploadUrl with Authorization: Bearer , then call artifact_commit_upload. The upload grant works only for that upload; never print it or repeat it in chat.", "After publishing, always give the user links.review first so they can see the exact version full screen and comment. Mention links.version second when the raw artifact is useful. Do not put content bootstrap URLs or credentials in chat.", "When publishing a new version, first call artifact_get and pass its current version ID as expectedCurrentVersionId. On conflict, inspect the new current version before retrying.", "Use a stable application idempotency key when retrying the same mutation. Use a new key only for an intentional new operation.", @@ -2367,7 +2372,7 @@ function capabilities( workflow: [ "Inspect one actual file or finished directory on the client.", "Call artifact_create_upload with portable file metadata.", - "Upload each exact file to its returned uploadUrl.", + "Upload each exact file to its returned uploadUrl with Authorization: Bearer .", "Call artifact_commit_upload with an idempotency key and optimistic version when updating.", "Inspect the returned immutable version and browser links.", ], @@ -2386,6 +2391,7 @@ function capabilities( function uploadPlan( applicationUrl: URL, upload: StagedUpload, + grant: Redacted.Redacted, ) { return { commit: { @@ -2395,7 +2401,7 @@ function uploadPlan( expiresAt: upload.expiresAt, files: upload.files.map((file) => ({ authorization: { - credential: "reuse_the_mcp_bearer_credential" as const, + credential: "upload_grant" as const, scheme: "Bearer" as const, }, method: "PUT" as const, @@ -2408,6 +2414,11 @@ function uploadPlan( })), manifestDigest: upload.manifest.digest, projectId: upload.projectId, + uploadGrant: { + expiresAt: upload.expiresAt, + scheme: "Bearer" as const, + token: Redacted.value(grant), + }, uploadId: upload.id, }; } diff --git a/src/storage/postgres-artifact-repository.ts b/src/storage/postgres-artifact-repository.ts index 852a8a2b..1b4bcbde 100644 --- a/src/storage/postgres-artifact-repository.ts +++ b/src/storage/postgres-artifact-repository.ts @@ -104,6 +104,7 @@ import type { RenameProject, RestoreArtifactVersion, SetProjectArchive, + StagedUploadGrantRecord, StagedUploadRepository, UpdateCommentReply, UpdateCommentThread, @@ -341,6 +342,11 @@ const stagedUploadFileRowSchema = z.object({ storageToken: z.string(), uploadedAt: z.string().nullable(), }); +const stagedUploadGrantRowSchema = z.object({ + grantDigest: z.string().nullable(), + principalId: z.string(), + projectId: z.string(), +}); const expiredStagedUploadRowSchema = z.object({ id: z.string(), storageToken: z.string(), @@ -2109,13 +2115,14 @@ export class PostgresArtifactRepository implements yield* this.#assertProjectActive(command.projectId); yield* sql`INSERT INTO staged_uploads ( installation_id, project_id, id, principal_id, status, manifest_digest, - entry_path, routing_mode, created_at, expires_at, committed_version_id + entry_path, routing_mode, created_at, expires_at, committed_version_id, + grant_digest ) VALUES ( ${installationId}, ${command.projectId}, ${command.id}, ${command.principalId}, 'open', ${command.manifest.digest}, ${command.manifest.entryPath}, ${command.manifest.routingMode}, ${command.createdAt}, - ${command.expiresAt}, NULL + ${command.expiresAt}, NULL, ${command.grantDigest} )`; for (const file of command.files) { yield* sql`INSERT INTO staged_upload_files ( @@ -2148,6 +2155,20 @@ export class PostgresArtifactRepository implements )); } + async findStagedUploadGrant( + uploadId: string, + ): Promise { + const installationId = this.#installationId; + return this.#database.run(Effect.gen(function*() { + const sql = yield* SqlClient; + const rows = yield* sql`SELECT grant_digest AS "grantDigest", + principal_id AS "principalId", project_id AS "projectId" + FROM staged_uploads + WHERE installation_id = ${installationId} AND id = ${uploadId}`; + return stagedUploadGrantRowSchema.nullable().parse(rows[0] ?? null); + })); + } + async listExpiredStagedUploads( expiredBefore: string, limit: number, diff --git a/src/storage/postgres-migrations.ts b/src/storage/postgres-migrations.ts index 7d64dcef..6a9a9c67 100644 --- a/src/storage/postgres-migrations.ts +++ b/src/storage/postgres-migrations.ts @@ -635,6 +635,11 @@ const widenRegisteredAgentKind = Effect.gen(function*() { for (const statement of statements) yield* sql.unsafe(statement); }); +const addStagedUploadGrant = Effect.gen(function*() { + const sql = yield* SqlClient; + yield* sql.unsafe("ALTER TABLE staged_uploads ADD COLUMN grant_digest TEXT"); +}); + const addArtifactSearchName = Effect.gen(function*() { const sql = yield* SqlClient; yield* sql.unsafe("ALTER TABLE artifacts ADD COLUMN search_name TEXT"); @@ -667,10 +672,11 @@ const migrationLoader = Migrator.fromRecord({ "0009_git_history_mirror": addGitHistoryMirror, "0010_agent_capabilities": widenRegisteredAgentKind, "0011_artifact_search_name": addArtifactSearchName, + "0012_staged_upload_grant": addStagedUploadGrant, }); /** Schema revision required by this Artifact Server build. */ -export const requiredPostgresSchemaVersion = 11; +export const requiredPostgresSchemaVersion = 12; /** Migration compatibility observed without changing Postgres. */ export interface PostgresMigrationStatus { @@ -758,6 +764,9 @@ export const readPostgresMigrationStatus = Effect.gen(function*() { }, { migration_id: 11, name: "artifact_search_name", + }, { + migration_id: 12, + name: "staged_upload_grant", }] as const; const observedRequiredHistory = rows.filter( (row) => row.migration_id <= requiredPostgresSchemaVersion, diff --git a/src/storage/sqlite-artifact-repository.ts b/src/storage/sqlite-artifact-repository.ts index b527b483..b8a32fce 100644 --- a/src/storage/sqlite-artifact-repository.ts +++ b/src/storage/sqlite-artifact-repository.ts @@ -120,6 +120,7 @@ import type { SetProjectArchive, SourceBindingRepository, SourceBindingWrite, + StagedUploadGrantRecord, StagedUploadRepository, } from "../core/ports.js"; import { @@ -326,6 +327,11 @@ const stagedUploadFileRowSchema = z.object({ storageToken: z.string(), uploadedAt: z.string().nullable(), }); +const stagedUploadGrantRowSchema = z.object({ + grantDigest: z.string().nullable(), + principalId: z.string(), + projectId: z.string(), +}); const expiredStagedUploadRowSchema = z.object({ id: z.string(), storageToken: z.string(), @@ -2350,8 +2356,9 @@ export class SqliteArtifactRepository implements .prepare( `INSERT INTO staged_uploads ( id, project_id, principal_id, status, manifest_digest, entry_path, - routing_mode, created_at, expires_at, committed_version_id - ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, NULL)`, + routing_mode, created_at, expires_at, committed_version_id, + grant_digest + ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, NULL, ?)`, ) .run( command.id, @@ -2362,6 +2369,7 @@ export class SqliteArtifactRepository implements command.manifest.routingMode, command.createdAt, command.expiresAt, + command.grantDigest, ); const insertFile = this.#database.prepare( @@ -2400,6 +2408,19 @@ export class SqliteArtifactRepository implements ); } + findStagedUploadGrant(uploadId: string): Promise { + return Promise.resolve().then(() => { + const row = this.#database + .prepare( + `SELECT grant_digest AS grantDigest, principal_id AS principalId, + project_id AS projectId + FROM staged_uploads WHERE id = ?`, + ) + .get(uploadId); + return stagedUploadGrantRowSchema.nullable().parse(row ?? null); + }); + } + listExpiredStagedUploads( expiredBefore: string, limit: number, @@ -4808,6 +4829,7 @@ export class SqliteArtifactRepository implements created_at TEXT NOT NULL, expires_at TEXT NOT NULL, committed_version_id TEXT REFERENCES versions(id), + grant_digest TEXT, CHECK ( (status = 'open' AND committed_version_id IS NULL) OR (status = 'committed' AND committed_version_id IS NOT NULL) @@ -4901,6 +4923,7 @@ export class SqliteArtifactRepository implements this.#widenRegisteredAgentsIfNeeded(); this.#addSourceBindingColumnsIfMissing(); this.#addGitHistoryMirrorTablesIfMissing(); + this.#addStagedUploadGrantIfMissing(); this.#database.exec(` CREATE INDEX IF NOT EXISTS projects_active_created ON projects (archived_at, created_at, id); @@ -4908,6 +4931,13 @@ export class SqliteArtifactRepository implements this.#database.exec(`PRAGMA user_version = ${requiredSqliteSchemaVersion};`); } + /** Give staged uploads a nullable grant digest; older uploads keep none. */ + #addStagedUploadGrantIfMissing(): void { + if (!this.#tableColumns("staged_uploads").includes("grant_digest")) { + this.#database.exec("ALTER TABLE staged_uploads ADD COLUMN grant_digest TEXT"); + } + } + #addArtifactSearchNameIfMissing(): void { if (!this.#tableColumns("artifacts").includes("search_name")) { this.#database.exec("ALTER TABLE artifacts ADD COLUMN search_name TEXT"); diff --git a/src/storage/sqlite-schema.ts b/src/storage/sqlite-schema.ts index eca9c632..2854d995 100644 --- a/src/storage/sqlite-schema.ts +++ b/src/storage/sqlite-schema.ts @@ -6,7 +6,7 @@ import {z} from "zod"; const userVersionRowSchema = z.object({user_version: z.number().int().nonnegative()}); /** SQLite schema revision required by this Artifact Server build. */ -export const requiredSqliteSchemaVersion = 12; +export const requiredSqliteSchemaVersion = 13; /** Compact database compatibility observed without changing the file. */ export interface SqliteMigrationStatus { diff --git a/tests/conformance/mcp-modern-http.test.ts b/tests/conformance/mcp-modern-http.test.ts index 14407021..14abfdab 100644 --- a/tests/conformance/mcp-modern-http.test.ts +++ b/tests/conformance/mcp-modern-http.test.ts @@ -48,7 +48,7 @@ const toolCallResultSchema = z.object({ }); const uploadPlanSchema = z.object({ authorization: z.object({ - credential: z.literal("reuse_the_mcp_bearer_credential"), + credential: z.literal("upload_grant"), scheme: z.literal("Bearer"), }), method: z.literal("PUT"), @@ -59,6 +59,11 @@ const uploadPlanSchema = z.object({ const createUploadResultSchema = z.object({ files: z.array(uploadPlanSchema).min(1), manifestDigest: z.string(), + uploadGrant: z.object({ + expiresAt: z.string(), + scheme: z.literal("Bearer"), + token: z.string().startsWith("as_upg_"), + }), uploadId: z.string(), }); const sourceBindingResultSchema = z.object({ @@ -403,6 +408,7 @@ describe("modern MCP HTTP", () => { const filePlan = upload.files[0]; if (filePlan === undefined) throw new Error("The MCP upload plan has no file."); + // An API key still authorizes the file PUT; the grant is not the only way. const uploaded = await fetch(filePlan.uploadUrl, { body: bytes, headers: {Authorization: `Bearer ${installation.apiToken}`}, @@ -555,7 +561,6 @@ describe("modern MCP HTTP", () => { ); await uploadMcpFiles( updatedUpload, - installation.apiToken, new Map([ ["assets/data.bin", assetBytes], ["index.html", indexBytes], @@ -1062,9 +1067,9 @@ function declaredMcpFile( async function uploadMcpFiles( upload: z.infer, - token: string, files: ReadonlyMap, ): Promise { + const token = upload.uploadGrant.token; await Promise.all(upload.files.map(async (plan) => { const bytes = files.get(plan.path); if (bytes === undefined) { diff --git a/tests/conformance/pub-014-upload-grant.test.ts b/tests/conformance/pub-014-upload-grant.test.ts new file mode 100644 index 00000000..16ae8ea1 --- /dev/null +++ b/tests/conformance/pub-014-upload-grant.test.ts @@ -0,0 +1,518 @@ +import {createHash} from "node:crypto"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from "@modelcontextprotocol/server"; +import {Effect, ManagedRuntime, Redacted, Result} from "effect"; +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import {z} from "zod"; + +import type {ApplicationRuntime} from "../../src/application/application-runtime.js"; +import type {BearerCredentialVerifier} from "../../src/application/authentication.js"; +import {StagedUploadService} from "../../src/application/staged-upload.js"; +import { + membershipRoles, + principalCapabilities, + principalKinds, + type Principal, +} from "../../src/core/identity.js"; +import {AuthenticationRequired} from "../../src/core/errors.js"; +import type {Clock} from "../../src/core/ports.js"; +import {SystemIdGenerator} from "../../src/core/system.js"; +import {createLocalApplicationLayer} from "../../src/local/create-local-application-layer.js"; +import {LocalBlobStore} from "../../src/storage/local-blob-store.js"; +import {LocalStagingStore} from "../../src/storage/local-staging-store.js"; +import {SqliteArtifactRepository} from "../../src/storage/sqlite-artifact-repository.js"; +import {SqliteIdentityRepository} from "../../src/storage/sqlite-identity-repository.js"; +import { + createTestInstallation, + removeTestInstallation, + type RunningTestServer, + startTestServer, + type TestInstallation, +} from "../support/runtime-harness.js"; + +const protocolVersion = "2026-07-28"; +const mcpOnlyToken = "mcp-oauth-only-member-token"; +const uploadPlanSchema = z.object({ + files: z.array(z.object({ + authorization: z.object({ + credential: z.literal("upload_grant"), + scheme: z.literal("Bearer"), + }), + path: z.string(), + uploadUrl: z.url(), + })).min(1), + uploadGrant: z.object({ + expiresAt: z.string(), + scheme: z.literal("Bearer"), + token: z.string().startsWith("as_upg_"), + }), + uploadId: z.string(), +}); +const toolCallResultSchema = z.object({ + result: z.object({ + isError: z.boolean().optional(), + structuredContent: z.unknown(), + }).loose(), +}); + +const grantedBytes = new TextEncoder().encode("granted upload"); +const declaredFile = { + mediaType: "text/plain", + path: "proof.txt", + sha256: createHash("sha256").update(grantedBytes).digest("hex"), + size: grantedBytes.byteLength, +}; + +describe("upload grants: staged upload service", () => { + let clock: ControlledClock; + let dataDirectory: string; + let identityRepository: SqliteIdentityRepository; + let repository: SqliteArtifactRepository; + let runtime: ApplicationRuntime; + + beforeEach(async () => { + dataDirectory = await mkdtemp(path.join(tmpdir(), "artifact-upload-grant-service-")); + clock = new ControlledClock(new Date("2026-10-12T08:00:00.000Z")); + const databasePath = path.join(dataDirectory, "artifact-server.db"); + repository = new SqliteArtifactRepository(databasePath); + identityRepository = new SqliteIdentityRepository(databasePath); + runtime = ManagedRuntime.make(createLocalApplicationLayer({ + apiToken: Redacted.make("test-api-token"), + blobs: new LocalBlobStore(path.join(dataDirectory, "blobs")), + bootstrapAdministratorEmail: "admin@example.test", + clock, + dispatches: repository, + externalApiBearerVerifier: null, + externalMcpBearerVerifier: null, + externalMcpOAuthVerifier: null, + ids: new SystemIdGenerator(), + identityRepository, + installationId: "test-installation", + interactiveIdentityProvider: null, + localBootstrapCredential: null, + protectBootstrapAdministrator: false, + repository, + staging: new LocalStagingStore(path.join(dataDirectory, "staging")), + })); + await runtime.context(); + }); + + afterEach(async () => { + await runtime.dispose(); + identityRepository.close(); + repository.close(); + await rm(dataDirectory, {force: true, recursive: true}); + }); + + const createGranted = () => + runStaged(runtime, (service) => + service.createUploadWithGrant({ + entryPath: declaredFile.path, + files: [declaredFile], + principal: owner, + }) + ); + + test("issues a grant once, stores only its digest, and accepts it for its own file", async () => { + expect.hasAssertions(); + const {grant, upload} = await createGranted(); + const token = Redacted.value(grant); + expect(token.startsWith("as_upg_")).toBe(true); + const stored = await repository.findStagedUploadGrant(upload.id); + expect(stored?.grantDigest).toBe(createHash("sha256").update(token).digest("hex")); + expect(JSON.stringify(stored)).not.toContain(token); + const slot = upload.files[0]; + if (slot === undefined) throw new Error("The upload has no file slot."); + + const uploaded = await runStaged(runtime, (service) => + service.uploadFileWithGrant({ + body: byteStream(grantedBytes), + grant, + projectId: null, + storageToken: slot.storageToken, + uploadId: upload.id, + }) + ); + expect(uploaded.files[0]?.uploadedAt).not.toBeNull(); + }); + + test("refuses a wrong grant, another upload's grant, an unknown upload, and the wrong project alike", async () => { + expect.hasAssertions(); + const first = await createGranted(); + const second = await createGranted(); + const slot = first.upload.files[0]; + if (slot === undefined) throw new Error("The upload has no file slot."); + const attempts = [ + {grant: `as_upg_${"A".repeat(43)}`, projectId: null, uploadId: first.upload.id}, + {grant: Redacted.value(second.grant), projectId: null, uploadId: first.upload.id}, + {grant: Redacted.value(first.grant), projectId: null, uploadId: "upl_missing"}, + { + grant: Redacted.value(first.grant), + projectId: "prj_00000000-0000-4000-8000-000000000000", + uploadId: first.upload.id, + }, + ]; + await Promise.all(attempts.map((attempt) => + expectStagedFailure(runtime, "AuthenticationRequired", (service) => + service.uploadFileWithGrant({ + body: byteStream(grantedBytes), + grant: Redacted.make(attempt.grant), + projectId: attempt.projectId, + storageToken: slot.storageToken, + uploadId: attempt.uploadId, + }) + ) + )); + }); + + test("an upload created without a grant refuses every grant", async () => { + expect.hasAssertions(); + const plain = await runStaged(runtime, (service) => + service.createUpload({ + entryPath: declaredFile.path, + files: [declaredFile], + principal: owner, + }) + ); + const {grant} = await createGranted(); + const slot = plain.files[0]; + if (slot === undefined) throw new Error("The upload has no file slot."); + await expectStagedFailure(runtime, "AuthenticationRequired", (service) => + service.uploadFileWithGrant({ + body: byteStream(grantedBytes), + grant, + projectId: null, + storageToken: slot.storageToken, + uploadId: plain.id, + }) + ); + expect((await repository.findStagedUploadGrant(plain.id))?.grantDigest).toBeNull(); + }); + + test("a matching grant is refused once the upload expired or was committed", async () => { + expect.hasAssertions(); + const expiring = await createGranted(); + const expiringSlot = expiring.upload.files[0]; + if (expiringSlot === undefined) throw new Error("The upload has no file slot."); + clock.set(new Date("2026-10-12T09:00:00.000Z")); + await expectStagedFailure(runtime, "UploadExpired", (service) => + service.uploadFileWithGrant({ + body: byteStream(grantedBytes), + grant: expiring.grant, + projectId: null, + storageToken: expiringSlot.storageToken, + uploadId: expiring.upload.id, + }) + ); + + const fresh = await createGranted(); + const freshSlot = fresh.upload.files[0]; + if (freshSlot === undefined) throw new Error("The upload has no file slot."); + const upload = (service: StagedUploadService["Service"]) => + service.uploadFileWithGrant({ + body: byteStream(grantedBytes), + grant: fresh.grant, + projectId: null, + storageToken: freshSlot.storageToken, + uploadId: fresh.upload.id, + }); + await runStaged(runtime, upload); + await runStaged(runtime, (service) => + service.commitUpload({ + idempotencyKey: "upload-grant-service-commit", + principal: owner, + target: { + accessSetting: "account_required", + kind: "new_artifact", + name: "Granted upload", + }, + uploadId: fresh.upload.id, + }) + ); + await expectStagedFailure(runtime, "UploadClosed", upload); + }); +}); + +const owner: Principal = { + authorizedByPrincipalId: null, + capabilities: [ + principalCapabilities.createArtifact, + principalCapabilities.issueContentSession, + principalCapabilities.publishAnyArtifact, + ], + displayName: "Upload grant fixture", + id: "principal-owner", + installationId: "test-installation", + kind: principalKinds.service, + membershipRole: membershipRoles.member, +}; + +class ControlledClock implements Clock { + #current: Date; + + constructor(initial: Date) { + this.#current = initial; + } + + now(): Date { + return new Date(this.#current); + } + + set(current: Date): void { + this.#current = current; + } +} + +function byteStream(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start: (controller) => { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +function runStaged( + runtime: ApplicationRuntime, + operation: (service: StagedUploadService["Service"]) => Effect.Effect, +): Promise { + return runtime.runPromise(StagedUploadService.use(operation)); +} + +async function expectStagedFailure( + runtime: ApplicationRuntime, + expectedTag: E["_tag"], + operation: (service: StagedUploadService["Service"]) => Effect.Effect, +): Promise { + const result = await runtime.runPromise( + Effect.result(StagedUploadService.use(operation)), + ); + const actualTag = Result.match(result, { + onFailure: (failure) => failure._tag, + onSuccess: () => "UnexpectedSuccess", + }); + expect(actualTag).toBe(expectedTag); +} + +describe("upload grants: HTTP and MCP", () => { + let clock: ControlledClock; + let installation: TestInstallation; + let output: string[]; + let server: RunningTestServer; + + beforeEach(async () => { + installation = await createTestInstallation(); + clock = new ControlledClock(new Date()); + output = []; + const capture = (chunk: string | Uint8Array): boolean => { + output.push(Buffer.from(chunk).toString("utf8")); + return true; + }; + vi.spyOn(process.stdout, "write").mockImplementation(capture); + vi.spyOn(process.stderr, "write").mockImplementation(capture); + // A credential only MCP accepts, like an Entra access token: /api/ refuses it. + const mcpOnlyVerifier: BearerCredentialVerifier = { + verify: (credential) => Redacted.value(credential) === mcpOnlyToken + ? Effect.succeed({ + authorizedByPrincipalId: null, + capabilities: [ + principalCapabilities.createArtifact, + principalCapabilities.readArtifacts, + ], + displayName: "MCP-only member", + id: "mcp-only-member", + installationId: "local", + kind: principalKinds.service, + membershipRole: membershipRoles.member, + }) + : Effect.fail(new AuthenticationRequired({ + message: "The external token is invalid.", + })), + }; + server = await startTestServer(installation, { + clock, + completedRequestLogSampleRate: 1, + externalMcpBearerVerifier: mcpOnlyVerifier, + }); + }); + + afterEach(async () => { + await server.stop(); + vi.restoreAllMocks(); + await removeTestInstallation(installation); + }); + + const createPlan = async ( + files: readonly {readonly bytes: Uint8Array; readonly path: string}[], + ) => { + const result = await callTool(server, mcpOnlyToken, "artifact_create_upload", { + entryPath: files[0]?.path ?? "index.html", + files: files.map(({bytes, path: filePath}) => ({ + mediaType: filePath.endsWith(".css") ? "text/css" : "text/html", + path: filePath, + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + })), + }); + expect(result.isError).not.toBe(true); + return uploadPlanSchema.parse(result.structuredContent); + }; + const commit = (uploadId: string, idempotencyKey: string) => + callTool(server, mcpOnlyToken, "artifact_commit_upload", { + idempotencyKey, + target: { + accessSetting: "account_required", + kind: "new_artifact", + name: "Granted upload", + }, + uploadId, + }); + + test("PUB-014-B: an MCP client without an API key uploads with the returned grant and commits through MCP", async () => { + expect.hasAssertions(); + const index = new TextEncoder().encode("

granted

"); + const style = new TextEncoder().encode("h1{color:red}"); + const plan = await createPlan([ + {bytes: index, path: "index.html"}, + {bytes: style, path: "style.css"}, + ]); + const first = plan.files[0]; + if (first === undefined) throw new Error("The upload plan is empty."); + + // The MCP credential itself still cannot reach /api/. + expect((await sendFile(first.uploadUrl, mcpOnlyToken, index)).status).toBe(401); + + const bodies = new Map([["index.html", index], ["style.css", style]]); + const responses = await Promise.all(plan.files.map((file) => + sendFile(file.uploadUrl, plan.uploadGrant.token, bodies.get(file.path) ?? new Uint8Array()) + )); + expect(responses.map(({status}) => status)).toEqual([200, 200]); + + const committed = await commit(plan.uploadId, "upload-grant-http-commit"); + expect(committed.isError).not.toBe(true); + expect(committed.structuredContent).toMatchObject({ + links: {review: expect.any(String)}, + }); + expect(output.join("")).not.toContain(plan.uploadGrant.token); + }); + + test("PUB-014-F: a grant is refused everywhere except its own upload's file uploads", async () => { + expect.hasAssertions(); + const bytes = new TextEncoder().encode("

a

"); + const [own, other] = await Promise.all([ + createPlan([{bytes, path: "index.html"}]), + createPlan([{bytes, path: "index.html"}]), + ]); + const ownFile = own.files[0]; + const otherFile = other.files[0]; + if (ownFile === undefined || otherFile === undefined) { + throw new Error("An upload plan is empty."); + } + const grant = own.uploadGrant.token; + const ownPath = ownFile.uploadUrl.split("?")[0] ?? ownFile.uploadUrl; + const changedGrant = `${grant.slice(0, -1)}${grant.endsWith("A") ? "B" : "A"}`; + const refused = await Promise.all([ + sendFile(otherFile.uploadUrl, grant, bytes), + sendFile(`${ownPath}/`, grant, bytes), + sendFile(`${ownPath}/extra`, grant, bytes), + sendFile(ownFile.uploadUrl, grant, bytes, "POST"), + fetch(`${server.baseUrl}/api/v1/artifacts`, { + headers: {Authorization: `Bearer ${grant}`}, + }), + fetch(`${server.baseUrl}/api/v1/uploads/${own.uploadId}/commit`, { + body: "{}", + headers: { + Authorization: `Bearer ${grant}`, + "Content-Type": "application/json", + "Idempotency-Key": "grant-must-not-commit", + }, + method: "POST", + }), + sendFile( + ownFile.uploadUrl.replace( + /projectId=[^&]+/u, + "projectId=prj_00000000-0000-4000-8000-000000000000", + ), + grant, + bytes, + ), + sendFile(ownFile.uploadUrl, changedGrant, bytes), + ]); + expect(refused.map(({status}) => status)).toEqual(Array(8).fill(401)); + + expect((await sendFile(ownFile.uploadUrl, grant, bytes)).status).toBe(200); + expect((await commit(own.uploadId, "upload-grant-http-closed")).isError).not.toBe(true); + expect((await sendFile(ownFile.uploadUrl, grant, bytes)).status).toBe(409); + + clock.set(new Date(Date.parse(other.uploadGrant.expiresAt) + 1_000)); + expect((await sendFile(otherFile.uploadUrl, other.uploadGrant.token, bytes)).status) + .toBe(410); + }); +}); + +function sendFile( + url: string, + token: string, + bytes: Uint8Array, + method: "POST" | "PUT" = "PUT", +): Promise { + return fetch(url, { + body: Buffer.from(bytes), + headers: {Authorization: `Bearer ${token}`}, + method, + }); +} + +interface McpParameters { + readonly [key: string]: McpParameterValue; +} + +type McpParameterValue = + | boolean + | number + | string + | null + | readonly McpParameterValue[] + | McpParameters; + +async function callTool( + server: RunningTestServer, + token: string, + name: string, + parameters: McpParameters, +) { + const response = await fetch(`${server.baseUrl}/mcp`, { + body: JSON.stringify({ + id: crypto.randomUUID(), + jsonrpc: "2.0", + method: "tools/call", + params: { + _meta: { + [CLIENT_CAPABILITIES_META_KEY]: {}, + [CLIENT_INFO_META_KEY]: {name: "artifact-server-test", version: "1"}, + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + }, + arguments: parameters, + name, + }, + }), + headers: { + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": "tools/call", + "Mcp-Name": name, + }, + method: "POST", + }); + expect(response.status).toBe(200); + return toolCallResultSchema.parse(await response.json()).result; +} diff --git a/tests/integration/external-storage-runtime.test.ts b/tests/integration/external-storage-runtime.test.ts index e6f3fd25..c560dd36 100644 --- a/tests/integration/external-storage-runtime.test.ts +++ b/tests/integration/external-storage-runtime.test.ts @@ -34,6 +34,7 @@ import {requiredPostgresSchemaVersion} from import {PostgresArtifactRepository} from "../../src/storage/postgres-artifact-repository.js"; import {PostgresIdentityRepository} from "../../src/storage/postgres-identity-repository.js"; import {defaultProjectId} from "../../src/core/model.js"; +import {createManifest} from "../../src/manifest/create-manifest.js"; import type {NodeGitHistoryConfiguration} from "../../src/git-history/node-git-history-configuration.js"; import type {GitHistoryProviderHealthProbe} from @@ -517,6 +518,7 @@ describe.sequential("external-storage Postgres and S3 runtime", () => { "ALTER TABLE actions DROP COLUMN project_id CASCADE", "ALTER TABLE idempotency_records DROP COLUMN project_id CASCADE", "ALTER TABLE versions DROP COLUMN project_id CASCADE", + "ALTER TABLE staged_uploads DROP COLUMN grant_digest", "ALTER TABLE artifacts DROP COLUMN search_name", "ALTER TABLE artifacts DROP COLUMN project_id CASCADE", "ALTER TABLE login_attempts DROP COLUMN nonce", @@ -2016,6 +2018,62 @@ describe.sequential("external-storage Postgres and S3 runtime", () => { await server.stop(); }); + test("external-storage foundation: Postgres stores staged upload grant digests per installation", async () => { + expect.hasAssertions(); + const identity = { + apiToken: managedTestKey("postgres-upload-grant"), + installationId: `postgres-upload-grant-${randomUUID()}`, + }; + const server = await startInProcessExternalStorageServer(environment, identity); + const database = await PostgresDatabase.open({ + maxConnections: 2, + url: Redacted.make(environment.databaseUrl), + }, "validate"); + try { + const repository = await PostgresArtifactRepository.open( + database, + identity.installationId, + ); + const other = await PostgresArtifactRepository.open( + database, + `postgres-upload-grant-other-${randomUUID()}`, + ); + const manifest = createManifest({ + entryPath: "index.html", + files: [{ + mediaType: "text/html", + path: "index.html", + sha256: "b".repeat(64), + size: 4, + }], + routingMode: "static", + }); + const uploadId = `upl_${randomUUID()}`; + await repository.createStagedUpload({ + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + files: manifest.entries.map((entry) => ({ + entry, + storageToken: randomUUID().replaceAll("-", ""), + })), + grantDigest: "c".repeat(64), + id: uploadId, + manifest, + principalId: "member_owner", + projectId: defaultProjectId, + }); + expect(await repository.findStagedUploadGrant(uploadId)).toEqual({ + grantDigest: "c".repeat(64), + principalId: "member_owner", + projectId: defaultProjectId, + }); + expect(await other.findStagedUploadGrant(uploadId)).toBeNull(); + } finally { + await database.close(); + await server.stop(); + } + }); + test("external-storage foundation: Postgres serves the dispatch mailbox, lease reclaim, and consumptive comment listings", async () => { expect.hasAssertions(); const dispatchIdentity = { diff --git a/tests/storage/sqlite-source-binding.test.ts b/tests/storage/sqlite-source-binding.test.ts index dbaf1f54..100eebfc 100644 --- a/tests/storage/sqlite-source-binding.test.ts +++ b/tests/storage/sqlite-source-binding.test.ts @@ -259,6 +259,7 @@ async function stageUpload( createdAt, expiresAt: "2026-12-31T00:00:00.000Z", files, + grantDigest: null, id: uploadId, manifest, principalId, diff --git a/tests/storage/staged-upload-grant-storage.test.ts b/tests/storage/staged-upload-grant-storage.test.ts new file mode 100644 index 00000000..6a7ab32a --- /dev/null +++ b/tests/storage/staged-upload-grant-storage.test.ts @@ -0,0 +1,116 @@ +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; + +import {afterEach, beforeEach, describe, expect, test} from "vitest"; + +import type {CreateStagedUpload} from "../../src/core/ports.js"; +import {createManifest} from "../../src/manifest/create-manifest.js"; +import {SqliteArtifactRepository} from "../../src/storage/sqlite-artifact-repository.js"; +import {requiredSqliteSchemaVersion} from "../../src/storage/sqlite-schema.js"; + +describe("staged upload grant storage on SQLite", () => { + let directory: string; + let databasePath: string; + + beforeEach(async () => { + directory = await mkdtemp(path.join(tmpdir(), "artifact-upload-grant-")); + databasePath = path.join(directory, "artifact-server.db"); + }); + + afterEach(async () => { + await rm(directory, {force: true, recursive: true}); + }); + + test("stores the grant digest and finds it by upload id alone", async () => { + const repository = new SqliteArtifactRepository(databasePath); + try { + const projectId = await defaultProjectId(repository); + await repository.createStagedUpload( + stagedUpload(projectId, "upl_with_grant", "a".repeat(64)), + ); + await repository.createStagedUpload( + stagedUpload(projectId, "upl_without_grant", null), + ); + + expect(await repository.findStagedUploadGrant("upl_with_grant")).toEqual({ + grantDigest: "a".repeat(64), + principalId: "member_owner", + projectId, + }); + expect(await repository.findStagedUploadGrant("upl_without_grant")).toEqual({ + grantDigest: null, + principalId: "member_owner", + projectId, + }); + expect(await repository.findStagedUploadGrant("upl_missing")).toBeNull(); + } finally { + repository.close(); + } + }); + + test("a schema 12 database gains the column with existing uploads left without a grant", async () => { + const first = new SqliteArtifactRepository(databasePath); + const projectId = await defaultProjectId(first); + await first.createStagedUpload(stagedUpload(projectId, "upl_before_upgrade", null)); + first.close(); + + const database = new DatabaseSync(databasePath); + database.exec(` + ALTER TABLE staged_uploads DROP COLUMN grant_digest; + PRAGMA user_version = 12; + `); + database.close(); + + const upgraded = new SqliteArtifactRepository(databasePath); + try { + expect(await upgraded.findStagedUploadGrant("upl_before_upgrade")).toMatchObject({ + grantDigest: null, + }); + const check = new DatabaseSync(databasePath, {readOnly: true}); + try { + expect(check.prepare("PRAGMA user_version").get()).toEqual({ + user_version: requiredSqliteSchemaVersion, + }); + } finally { + check.close(); + } + } finally { + upgraded.close(); + } + }); +}); + +async function defaultProjectId(repository: SqliteArtifactRepository): Promise { + const project = (await repository.listProjects())[0]; + if (project === undefined) throw new Error("The default project is missing."); + return project.id; +} + +function stagedUpload( + projectId: string, + id: string, + grantDigest: string | null, +): CreateStagedUpload { + const manifest = createManifest({ + entryPath: "index.html", + files: [{ + mediaType: "text/html", + path: "index.html", + sha256: "b".repeat(64), + size: 4, + }], + routingMode: "static", + }); + return { + createdAt: "2026-10-12T08:00:00.000Z", + expiresAt: "2026-10-12T09:00:00.000Z", + files: manifest.entries.map((entry) => ({entry, storageToken: `${id}_file`})), + grantDigest, + id, + manifest, + principalId: "member_owner", + projectId, + }; +}