Skip to content
Merged
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
6 changes: 6 additions & 0 deletions scripts/verify-spa-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):");

Expand Down
34 changes: 34 additions & 0 deletions workers/identity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 10 additions & 2 deletions workers/identity/src/route-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 —
Expand Down
27 changes: 19 additions & 8 deletions workers/identity/test/route-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<string>([...CEREMONY_PREFIXES, ...SUBTREE_ONLY_PREFIXES, ...DEFENSIVE_PREFIXES]);
const denylist = new Set<string>(P03_DENYLIST_PREFIXES);
const manifestOnly = [...manifest].filter((p) => !denylist.has(p)).sort();
Expand Down
59 changes: 59 additions & 0 deletions workers/identity/test/static-assets-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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");
});
});
9 changes: 9 additions & 0 deletions workers/identity/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -188,6 +195,7 @@ run_worker_first = [
"/__test", "/__test/*",
"/account/*",
"/vault", "/vault/*",
"/mcp", "/mcp/*",
"/",
"!/oauth/callback",
]
Expand Down Expand Up @@ -400,6 +408,7 @@ run_worker_first = [
"/__test", "/__test/*",
"/account/*",
"/vault", "/vault/*",
"/mcp", "/mcp/*",
"/",
"!/oauth/callback",
]
Expand Down