Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions deploy/cloudflare/src/d1-artifact-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions deploy/cloudflare/src/d1-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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(`
Expand All @@ -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<void> {
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<void> {
Expand Down
2 changes: 1 addition & 1 deletion deploy/cloudflare/tests/agent-dispatch-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
40 changes: 39 additions & 1 deletion deploy/cloudflare/tests/comment-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"),
Expand Down Expand Up @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions deploy/cloudflare/tests/worker-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<h1>D1 grant</h1>");
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();
Expand Down Expand Up @@ -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<T>(
name: string,
parameters: McpParameters,
schema: z.ZodType<T>,
): Promise<T> {
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);
}
6 changes: 3 additions & 3 deletions project/spec/artifact-server-mcp-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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. |
Expand Down
15 changes: 14 additions & 1 deletion project/spec/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
Loading