diff --git a/scripts/verify-spa-routing.ts b/scripts/verify-spa-routing.ts index d04e798..526c421 100644 --- a/scripts/verify-spa-routing.ts +++ b/scripts/verify-spa-routing.ts @@ -80,6 +80,12 @@ async function main() { const webhook = await probe("/billing/webhook"); // GET on a POST-only route: the worker 404s; the SPA would have shelled it assert(webhook.status === 404 && !webhook.body.includes('id="root"'), "/billing/webhook (GET) → worker 404 (worker-owned, not the SPA shell)"); + // DEFENSIVE_PREFIXES backstop (parachute-cloud#196): on staging the zone + // route to the vault worker doesn't exist, so this IS the live case the + // backstop exists for — /mcp must 503 loudly, never the SPA shell. + const mcp = await probe("/mcp"); + assert(mcp.status === 503 && mcp.contentType.includes("json") && mcp.body.includes("mcp_route_missing"), "/mcp → worker 503 mcp_route_missing (DEFENSIVE_PREFIXES backstop, not the SPA shell)", `status ${mcp.status}`); + // --- SPA: everything else falls through to the Static-Assets shell ---------- console.log("\nSPA (must serve index.html):"); diff --git a/workers/identity/src/index.ts b/workers/identity/src/index.ts index 7d2f94d..f336cf6 100644 --- a/workers/identity/src/index.ts +++ b/workers/identity/src/index.ts @@ -422,6 +422,40 @@ function vaultRouteMissing(): Response { app.all("/vault", vaultRouteMissing); app.all("/vault/*", vaultRouteMissing); +/** + * The /mcp defensive backstop (route-manifest.ts DEFENSIVE_PREFIXES), + * primarily for the my.parachute.computer one-origin door (parachute-cloud#196): + * a Cloudflare zone route on my.parachute.computer/mcp* is meant to dispatch + * the canonical MCP connector endpoint straight to the VAULT worker at the + * platform layer, ahead of this worker's my. Custom Domain, so in normal + * operation THIS worker never answers a my./mcp* request. If that zone route + * is ever deleted or misconfigured, this is the fallback — and it must never + * silently serve the SPA shell as a 200 (which would make the canonical MCP + * connector URL look reachable while hiding the routing failure). A loud 503 + * is the honest answer: "the MCP route is unreachable here," not "here is an + * MCP endpoint." + * + * This handler goes live on EVERY host this worker serves (cloud., app., + * staging) the moment this PR deploys — not just my., and not only once the + * my. cutover happens. That's deliberate, not incidental: the identity + * worker's Static Assets fallback could otherwise silently serve the SPA shell + * at `/mcp` on any serving host. The my.-specific zone-route rationale above + * just explains why the pattern needed a name. + */ +function mcpRouteMissing(): Response { + return Response.json( + { + error: "route_missing", + error_type: "mcp_route_missing", + message: + "This identity worker never serves /mcp directly — /mcp is the canonical MCP connector endpoint, and requests are meant to be intercepted by a platform-layer route (a Cloudflare zone route) pointed at the vault worker before they reach here. If you expected the MCP connector at this URL, that route is missing or misconfigured on this origin.", + }, + { status: 503 }, + ); +} +app.all("/mcp", mcpRouteMissing); +app.all("/mcp/*", mcpRouteMissing); + /** * Serve the SPA shell (index.html) through the Static Assets binding (P1.1), * carrying the SPA Content-Security-Policy (P1.1.5). diff --git a/workers/identity/src/route-manifest.ts b/workers/identity/src/route-manifest.ts index 12de671..75d279d 100644 --- a/workers/identity/src/route-manifest.ts +++ b/workers/identity/src/route-manifest.ts @@ -42,7 +42,7 @@ * (`parachute-surface/packages/notes-ui/src/pwa-navigation-denylist.ts`) is the * OTHER half of the same guarantee: the installed service worker must let these * same ceremonies reach the origin instead of serving its cached shell. The two - * lists are the same set, with two DELIBERATE, documented differences — see + * lists are the same set, with three DELIBERATE, documented differences — see * `P03_DENYLIST_PREFIXES` + `KNOWN_PARITY_DIFFERENCES` in the test, which pins * the symmetric difference so neither can silently drift from the other. */ @@ -108,8 +108,16 @@ export const SUBTREE_ONLY_PREFIXES = ["/account"] as const; * registered handler (index.ts `vaultRouteMissing`) answers `503 route_missing` * — never the SPA shell, which would look like a working (but empty) vault to * an API/MCP client instead of an honest failure. + * + * `/mcp`: on my.parachute.computer, `/mcp*` is meant to be intercepted by a + * Cloudflare ZONE ROUTE that dispatches straight to the VAULT worker + * (`workers/vault/wrangler.toml`) — the canonical MCP connector endpoint is + * normally peeled off at the platform layer before this worker's my. Custom + * Domain sees it. This entry is the backstop for that route vanishing: the + * registered handler (index.ts `mcpRouteMissing`) answers `503 route_missing` + * instead of letting the SPA shell masquerade as the MCP endpoint. */ -export const DEFENSIVE_PREFIXES = ["/vault"] as const; +export const DEFENSIVE_PREFIXES = ["/vault", "/mcp"] as const; /** * The subset of CEREMONY_PREFIXES that has NO live route in `index.ts` yet — diff --git a/workers/identity/test/route-manifest.test.ts b/workers/identity/test/route-manifest.test.ts index ee4d81b..0d2246b 100644 --- a/workers/identity/test/route-manifest.test.ts +++ b/workers/identity/test/route-manifest.test.ts @@ -12,7 +12,7 @@ * added without updating the manifest fails HERE, before * the SPA fallback can swallow it; * 4. P0.3 parity — the set matches the parachute-app SW denylist, modulo - * two documented differences. + * three documented differences. */ import { describe, expect, test } from "vitest"; import { app } from "../src/index.ts"; @@ -126,7 +126,7 @@ describe("route manifest — the run_worker_first contract (P0.4)", () => { }); // (d) P0.3 parity — provably the same set as the parachute-app service-worker - // denylist, modulo the two documented differences. Maintained mirror (the two + // denylist, modulo the three documented differences. Maintained mirror (the two // repos don't share a package, so we can't import it); the comment points at // the source, and this test fails if THIS repo's set drifts from the agreed // shape. See parachute-app/src/pwa-navigation-denylist.ts. @@ -154,7 +154,7 @@ describe("route manifest — the run_worker_first contract (P0.4)", () => { "/u", // Phase B, reserved — /^\/u\// in the app denylist, no cloud route yet ]; - // The two DELIBERATE differences between the sets: + // The three DELIBERATE differences between the sets: // - /api, /u : SW-denylist-only. `/api` — the vault REST lives on a // DIFFERENT worker/origin (u.parachute.computer); the // identity worker has no /api route, so its Static Assets @@ -168,16 +168,27 @@ describe("route manifest — the run_worker_first contract (P0.4)", () => { // navigational (the SW's nav fallback only affects GET // navigations) and staging-only, so the SW needn't deny them; // run_worker_first includes them as genuinely server-owned. - const KNOWN_PARITY_DIFFERENCES = { swOnly: ["/api", "/u"], manifestOnly: ["/__test"] }; + // - /mcp : identity-manifest-only for this task (parachute-cloud#196). + // The parachute-app SW entry `/^\/mcp(\/|$)/` is a separate + // follow-up in the other repo, per PR #195's Judgment call, + // so this gap is intentional until that PR lands. + const KNOWN_PARITY_DIFFERENCES = { + swOnly: ["/api", "/u"], + manifestOnly: [ + "/__test", + "/mcp", // cloud#196 ships the identity half; parachute-app follows separately. + ], + }; test("the sets are identical modulo the documented differences", () => { // `/account` is a SUBTREE_ONLY prefix (its `/account/*` sub-tree is server- // owned; the bare `/account` is the SPA shell) — still part of the manifest's // server-owned set, and the SW denylist matches it (`/^\/account\//`). - // `DEFENSIVE_PREFIXES` (`/vault`) is folded in too — the app's denylist - // KNOWS about my./vault/* (PR #38, `/^\/vault\//`) even though it has no - // HTML ceremony page, so it belongs in this comparison same as any other - // server-owned prefix. + // `DEFENSIVE_PREFIXES` (`/vault`, `/mcp`) is folded in too — the app's + // denylist KNOWS about my./vault/* (PR #38, `/^\/vault\//`) even though it + // has no HTML ceremony page, so it belongs in this comparison same as any + // other server-owned prefix. `/mcp` is intentionally not in the mirror + // yet; the separate parachute-app follow-up is documented above. const manifest = new Set([...CEREMONY_PREFIXES, ...SUBTREE_ONLY_PREFIXES, ...DEFENSIVE_PREFIXES]); const denylist = new Set(P03_DENYLIST_PREFIXES); const manifestOnly = [...manifest].filter((p) => !denylist.has(p)).sort(); diff --git a/workers/identity/test/static-assets-routing.test.ts b/workers/identity/test/static-assets-routing.test.ts index c051187..0b6d065 100644 --- a/workers/identity/test/static-assets-routing.test.ts +++ b/workers/identity/test/static-assets-routing.test.ts @@ -89,6 +89,8 @@ describe("runsWorkerFirst — the runtime twin of CF's matcher (P1.1)", () => { "/__test/drip-run", "/vault", // Phase A1 — the my./vault/* defensive backstop (DEFENSIVE_PREFIXES) "/vault/some-name/mcp", + "/mcp", // parachute-cloud#196 — the canonical my./mcp* defensive backstop + "/mcp/some-vault-name", ]; test.each(ceremonies)("%s runs the WORKER first (never the SPA)", (path) => { expect(runsWorkerFirst(path)).toBe(true); @@ -132,6 +134,8 @@ describe("runsWorkerFirst — the runtime twin of CF's matcher (P1.1)", () => { "/settings", "/vault", "/vault/some-name/mcp", + "/mcp", + "/mcp/some-vault-name", ]; for (const p of samples) { expect(runsWorkerFirst(p), `${p}: matcher/manifest disagree`).toBe(isCeremonyPath(p)); @@ -249,3 +253,58 @@ describe("the /vault defensive backstop (Phase A1, DEFENSIVE_PREFIXES)", () => { expect(res.status).toBe(503); }); }); + +// --- 4. the my./mcp* defensive backstop (parachute-cloud#196) --------------- + +describe("the /mcp defensive backstop (parachute-cloud#196, DEFENSIVE_PREFIXES)", () => { + // In production this identity worker never actually answers my./mcp* — a + // Cloudflare zone route on the vault worker intercepts the canonical MCP + // connector URL at the platform layer, ahead of this worker's my. Custom + // Domain. This suite exercises the worker DIRECTLY (bypassing the zone route + // entirely, as vitest always does) to prove the fallback itself is correct — + // the case that matters is "the zone route vanished," which looks identical + // to this from the worker's POV. + test("GET /mcp → 503 mcp_route_missing, never the SPA shell", async () => { + const res = await worker.fetch(new Request("https://my.example/mcp"), env); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string; error_type: string }; + expect(body.error_type).toBe("mcp_route_missing"); + }); + + test("GET /mcp/anything → 503 route_missing, never a 200 SPA shell", async () => { + const res = await worker.fetch(new Request("https://my.example/mcp/anything"), env); + expect(res.status).toBe(503); + expect(res.headers.get("content-type") ?? "").toContain("application/json"); + const body = (await res.json()) as { error: string; error_type: string }; + expect(body.error_type).toBe("mcp_route_missing"); + }); + + test("POST /mcp (the MCP JSON-RPC verb) gets the same 503, not a 404 or a redirect", async () => { + const res = await worker.fetch( + new Request("https://my.example/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + env, + ); + expect(res.status).toBe(503); + }); + + // The two spellings a hand-typed or slightly-off connector URL most + // plausibly takes — a trailing slash, or a query string tacked on. Both + // must still hit the backstop, not fall through to the SPA shell. + test("GET /mcp/ (trailing slash) → 503 mcp_route_missing, never the SPA shell", async () => { + const res = await worker.fetch(new Request("https://my.example/mcp/"), env); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string; error_type: string }; + expect(body.error_type).toBe("mcp_route_missing"); + }); + + test("GET /mcp?foo=bar (query string) → 503 mcp_route_missing, never the SPA shell", async () => { + const res = await worker.fetch(new Request("https://my.example/mcp?foo=bar"), env); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string; error_type: string }; + expect(body.error_type).toBe("mcp_route_missing"); + }); +}); diff --git a/workers/identity/wrangler.toml b/workers/identity/wrangler.toml index 8904541..2f146ee 100644 --- a/workers/identity/wrangler.toml +++ b/workers/identity/wrangler.toml @@ -168,6 +168,13 @@ new_sqlite_classes = ["RateLimiterDO"] # 503s instead of the SPA shell if the vault worker's # my./vault/* zone route (workers/vault/wrangler.toml) # ever goes missing. +# `/mcp`, `/mcp/*` (parachute-cloud#196, +# DEFENSIVE_PREFIXES) are the analogous backstop for +# the canonical MCP connector URL: this worker 503s if +# the vault worker's my./mcp* zone route ever goes +# missing. The parachute-app service-worker denylist +# half is a separate follow-up per PR #195's Judgment +# call. # ============================================================================= [assets] directory = "./dist-assets" @@ -188,6 +195,7 @@ run_worker_first = [ "/__test", "/__test/*", "/account/*", "/vault", "/vault/*", + "/mcp", "/mcp/*", "/", "!/oauth/callback", ] @@ -400,6 +408,7 @@ run_worker_first = [ "/__test", "/__test/*", "/account/*", "/vault", "/vault/*", + "/mcp", "/mcp/*", "/", "!/oauth/callback", ]