clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/browser-cors-sync-tickets
lofi-node / tests / gate_test.ts
gate_test.ts 16.0K
1// Gate behavior against a stub upstream (no jazz, no iroh): routing,2// secret verification, prefix stripping, WS re-origination, revocation.34import { assert, assertEquals } from "@std/assert";5import { AppTicketStore, decodeAppTicket } from "../src/appticket.ts";6import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";78/** Stub upstream: echoes path+search+selected headers over HTTP; echoes9 * messages and reports path/protocol over WS. */10function startStubUpstream() {11 const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {12 const url = new URL(req.url);13 // Jazz-shaped catalogue routes for store-status (admin header required,14 // proving the gate injected the node's secret).15 if (url.pathname === "/apps/stub-app/schemas") {16 if (req.headers.get("x-jazz-admin-secret") !== "node-admin-secret") {17 return new Response("admin required", { status: 401 });18 }19 // Deliberately unordered `hashes`; head derives from publishedAt.20 return Response.json({21 hashes: ["hash-union", "hash-a"],22 schemas: [23 { hash: "hash-union", publishedAt: 2000 },24 { hash: "hash-a", publishedAt: 1000 },25 ],26 });27 }28 if (url.pathname === "/apps/stub-app/admin/permissions/head") {29 if (req.headers.get("x-jazz-admin-secret") !== "node-admin-secret") {30 return new Response("admin required", { status: 401 });31 }32 return Response.json({ head: "perm-head-3" });33 }34 if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {35 const protocol = req.headers.get("sec-websocket-protocol")?.split(",")[0].trim();36 const { socket, response } = Deno.upgradeWebSocket(req, protocol ? { protocol } : {});37 socket.addEventListener("open", () => {38 socket.send(39 JSON.stringify({ hello: url.pathname + url.search, protocol: protocol ?? null }),40 );41 });42 socket.addEventListener("message", (ev) => socket.send(ev.data));43 return response;44 }45 return new Response(46 JSON.stringify({47 path: url.pathname + url.search,48 admin: req.headers.get("x-jazz-admin-secret"),49 method: req.method,50 }),51 { headers: { "content-type": "application/json" } },52 );53 });54 return {55 url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,56 close: () => server.shutdown(),57 };58}5960async function withGate(61 mode: "open" | "ticket",62 fn: (ctx: {63 gateUrl: string;64 store: AppTicketStore;65 gate: ReturnType<typeof startGate>;66 }) => Promise<void>,67) {68 const upstream = startStubUpstream();69 const store = await AppTicketStore.load();70 const gate = startGate({71 port: 0,72 hostname: "127.0.0.1",73 target: () => upstream.url,74 mode,75 store,76 appId: "stub-app",77 adminSecret: "node-admin-secret",78 });79 try {80 await fn({ gateUrl: `http://127.0.0.1:${gate.port}`, store, gate });81 } finally {82 await gate.close();83 await upstream.close();84 }85}8687Deno.test({88 name: "gate: valid secret strips prefix, preserves search, streams headers",89 sanitizeOps: false,90 sanitizeResources: false,91 fn: () =>92 withGate("ticket", async ({ gateUrl, store }) => {93 const { secret } = await store.issue("t", "provision");94 const res = await fetch(95 `${gateUrl}/t/${secret}/apps/abc/admin/schema-connectivity?appId=xyz`,96 { headers: { "X-Jazz-Admin-Secret": "adm" } },97 );98 assertEquals(res.status, 200);99 const body = await res.json();100 assertEquals(body.path, "/apps/abc/admin/schema-connectivity?appId=xyz");101 assertEquals(body.admin, "node-admin-secret", "node injects its own admin secret");102 }),103});104105Deno.test({106 name: "gate: /health is open; everything else 401/404",107 sanitizeOps: false,108 sanitizeResources: false,109 fn: () =>110 withGate("ticket", async ({ gateUrl, store }) => {111 assertEquals((await (await fetch(`${gateUrl}/health`)).json()).path, "/health");112 assertEquals((await fetch(`${gateUrl}/apps/abc/ws`)).status, 404, "no ticket prefix → 404");113 const bogus = "A".repeat(43);114 assertEquals((await fetch(`${gateUrl}/t/${bogus}/apps/abc`)).status, 401);115 const { record, secret } = await store.issue();116 await store.revoke(record.id);117 assertEquals((await fetch(`${gateUrl}/t/${secret}/apps/abc`)).status, 401, "revoked → 401");118 }),119});120121Deno.test({122 name: "gate: browser HTTP requests receive CORS preflight and response headers",123 sanitizeOps: false,124 sanitizeResources: false,125 fn: () =>126 withGate("ticket", async ({ gateUrl, store }) => {127 const { secret } = await store.issue("browser", "provision");128 const url = `${gateUrl}/t/${secret}/derive-sync-ticket`;129 const preflight = await fetch(url, {130 method: "OPTIONS",131 headers: {132 origin: "https://demo.lofi.host",133 "access-control-request-method": "POST",134 "access-control-request-headers": "content-type, x-client-version",135 "access-control-request-private-network": "true",136 },137 });138 assertEquals(preflight.status, 204);139 assertEquals(preflight.headers.get("access-control-allow-origin"), "*");140 assert(141 preflight.headers.get("access-control-allow-methods")?.includes("POST"),142 "preflight must allow POST",143 );144 assertEquals(145 preflight.headers.get("access-control-allow-headers"),146 "content-type, x-client-version",147 );148 assertEquals(preflight.headers.get("access-control-allow-private-network"), "true");149150 const response = await fetch(url, {151 method: "POST",152 headers: { origin: "https://demo.lofi.host", "content-type": "application/json" },153 body: "{}",154 });155 assertEquals(response.status, 200);156 assertEquals(response.headers.get("access-control-allow-origin"), "*");157 await response.body?.cancel();158 }),159});160161Deno.test({162 name: "gate: WS round-trip with subprotocol echo and path stripping",163 sanitizeOps: false,164 sanitizeResources: false,165 fn: () =>166 withGate("ticket", async ({ gateUrl, store }) => {167 const { secret } = await store.issue();168 const ws = new WebSocket(169 `${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws?key=1`,170 ["jazz-sync"],171 );172 const messages: string[] = [];173 const done = new Promise<void>((resolve, reject) => {174 const timer = setTimeout(() => reject(new Error("ws timeout")), 8000);175 ws.addEventListener("message", (ev) => {176 messages.push(ev.data as string);177 if (messages.length === 2) {178 clearTimeout(timer);179 resolve();180 }181 });182 ws.addEventListener("error", () => {183 clearTimeout(timer);184 reject(new Error("ws error"));185 });186 });187 await new Promise<void>((resolve, reject) => {188 ws.addEventListener("open", () => resolve());189 ws.addEventListener("close", () => reject(new Error("closed before open")));190 });191 assertEquals(ws.protocol, "jazz-sync", "subprotocol negotiated through the gate");192 ws.send("ping through gate");193 await done;194 const hello = JSON.parse(messages[0]);195 assertEquals(hello.hello, "/apps/abc/ws?key=1", "upstream saw stripped path + search");196 assertEquals(hello.protocol, "jazz-sync");197 assertEquals(messages[1], "ping through gate", "echo round-trips");198 ws.close(1000);199 }),200});201202Deno.test({203 name: "gate: revocation closes live sockets with 4001",204 sanitizeOps: false,205 sanitizeResources: false,206 fn: () =>207 withGate("ticket", async ({ gateUrl, store, gate }) => {208 const { record, secret } = await store.issue("victim");209 const ws = new WebSocket(`${gateUrl.replace("http", "ws")}/t/${secret}/apps/abc/ws`);210 await new Promise<void>((resolve, reject) => {211 ws.addEventListener("open", () => resolve());212 ws.addEventListener("close", () => reject(new Error("closed before open")));213 });214 const live = gate.stats().find((entry) => entry.ticketId === record.id);215 assertEquals(live?.connections, 1);216217 const closed = new Promise<CloseEvent>((resolve) =>218 ws.addEventListener("close", (ev) => resolve(ev))219 );220 await store.revoke(record.id);221 const ev = await Promise.race([222 closed,223 new Promise<never>((_r, reject) =>224 setTimeout(() => reject(new Error("no revocation close within 6s")), 6000)225 ),226 ]);227 assertEquals(ev.code, CLOSE_TICKET_REVOKED);228 // The socket is gone; the last-seen stamp for the ticket remains.229 const after = gate.stats().find((entry) => entry.ticketId === record.id);230 assertEquals(after?.connections, 0);231 }),232});233234Deno.test({235 name: "gate: open mode proxies without ticket prefixes",236 sanitizeOps: false,237 sanitizeResources: false,238 fn: () =>239 withGate("open", async ({ gateUrl }) => {240 const res = await fetch(`${gateUrl}/apps/abc/schemas?x=1`);241 assertEquals((await res.json()).path, "/apps/abc/schemas?x=1");242 }),243});244245Deno.test({246 name: "gate: sync scope on admin paths → 401 with the invalid-ticket shape",247 sanitizeOps: false,248 sanitizeResources: false,249 fn: () =>250 withGate("ticket", async ({ gateUrl, store }) => {251 const { secret } = await store.issue("sync-only"); // default scope252 const res = await fetch(`${gateUrl}/t/${secret}/apps/stub-app/admin/schemas`, {253 method: "POST",254 body: "{}",255 });256 assertEquals(res.status, 401);257 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");258 // Non-admin paths still work for the same ticket.259 const ok = await fetch(`${gateUrl}/t/${secret}/apps/abc/anything`);260 assertEquals(ok.status, 200);261 await ok.body?.cancel();262 }),263});264265Deno.test({266 name: "gate: provision scope injects the node admin secret, strips inbound",267 sanitizeOps: false,268 sanitizeResources: false,269 fn: () =>270 withGate("ticket", async ({ gateUrl, store }) => {271 const { secret } = await store.issue("admin", "provision");272 // The stub echoes the admin header it received; the client sends a FAKE273 // one which must be replaced by the node's own.274 const res = await fetch(`${gateUrl}/t/${secret}/apps/abc/echo`, {275 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },276 });277 assertEquals((await res.json()).admin, "node-admin-secret", "injected, not forwarded");278 // Sync scope: inbound admin header is stripped, nothing injected.279 const { secret: syncSecret } = await store.issue("plain");280 const res2 = await fetch(`${gateUrl}/t/${syncSecret}/apps/abc/echo`, {281 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },282 });283 assertEquals((await res2.json()).admin, null, "stripped for sync scope");284 }),285});286287Deno.test({288 name: "gate: derive-sync-ticket mints a linked sync ticket that syncs through the gate",289 sanitizeOps: false,290 sanitizeResources: false,291 fn: () =>292 withGate("ticket", async ({ gateUrl, store }) => {293 const { record: parent, secret } = await store.issue("laptop-admin", "provision");294 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, {295 method: "POST",296 body: JSON.stringify({ label: "laptop" }),297 });298 assertEquals(res.status, 200);299 const body = await res.json() as { v: number; id: string; ticket: string };300 assertEquals(body.v, 1);301 const derived = decodeAppTicket(body.ticket);302 assert(derived !== null, "response carries a decodable lofisync1. ticket");303 assertEquals(derived.appId, "stub-app");304 assertEquals(derived.scope ?? "sync", "sync", "derived tickets are sync-scoped");305 assertEquals(derived.label, "laptop");306 assert(derived.url.startsWith(`${gateUrl}/t/`), "url uses the gate base");307 const record = (await store.list()).find((t) => t.id === body.id);308 assertEquals(record?.parentId, parent.id, "derived record links its parent");309310 // The derived ticket round-trips through the gate for sync traffic…311 const ok = await fetch(`${derived.url}/apps/abc/echo`, {312 headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },313 });314 assertEquals(ok.status, 200);315 assertEquals((await ok.json()).admin, null, "sync scope: no admin injection");316 // …but stays locked out of admin paths and further derivation.317 const admin = await fetch(`${derived.url}/apps/stub-app/admin/schemas`, {318 method: "POST",319 body: "{}",320 });321 assertEquals(admin.status, 401);322 assertEquals(await admin.json(), { error: "invalid_ticket" });323324 // Default label derives from the parent's label.325 const res2 = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });326 assertEquals(res2.status, 200);327 const derived2 = decodeAppTicket((await res2.json() as { ticket: string }).ticket);328 assertEquals(derived2?.label, "laptop-admin (sync)");329 }),330});331332Deno.test({333 name: "gate: derive-sync-ticket 401s sync scope and unknown secrets with the invalid shape",334 sanitizeOps: false,335 sanitizeResources: false,336 fn: () =>337 withGate("ticket", async ({ gateUrl, store }) => {338 const { secret } = await store.issue("sync-only"); // default scope339 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });340 assertEquals(res.status, 401);341 assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");342 const bogus = "A".repeat(43);343 const unknown = await fetch(`${gateUrl}/t/${bogus}/derive-sync-ticket`, { method: "POST" });344 assertEquals(unknown.status, 401);345 assertEquals(await unknown.json(), { error: "invalid_ticket" });346 }),347});348349Deno.test({350 name: "gate: revoking the parent cascades — derived ticket 401s, live sockets close 4001",351 sanitizeOps: false,352 sanitizeResources: false,353 fn: () =>354 withGate("ticket", async ({ gateUrl, store }) => {355 const { record: parent, secret } = await store.issue("admin", "provision");356 const res = await fetch(`${gateUrl}/t/${secret}/derive-sync-ticket`, { method: "POST" });357 const { ticket } = await res.json() as { ticket: string };358 const derived = decodeAppTicket(ticket);359 assert(derived !== null);360 const before = await fetch(`${derived.url}/apps/abc/echo`);361 assertEquals(before.status, 200);362 await before.body?.cancel();363364 const ws = new WebSocket(`${derived.url.replace("http", "ws")}/apps/abc/ws`);365 await new Promise<void>((resolve, reject) => {366 ws.addEventListener("open", () => resolve());367 ws.addEventListener("close", () => reject(new Error("closed before open")));368 });369 const closed = new Promise<CloseEvent>((resolve) =>370 ws.addEventListener("close", (ev) => resolve(ev))371 );372373 await store.revoke(parent.id);374 const rejected = await fetch(`${derived.url}/apps/abc/echo`);375 assertEquals(rejected.status, 401, "derived ticket dies with its parent");376 assertEquals(await rejected.json(), { error: "invalid_ticket" }, "same shape as revoked");377 const ev = await Promise.race([378 closed,379 new Promise<never>((_r, reject) =>380 setTimeout(() => reject(new Error("no cascade close within 6s")), 6000)381 ),382 ]);383 assertEquals(ev.code, CLOSE_TICKET_REVOKED, "sweep closes derived sockets too");384 }),385});386387Deno.test({388 name: "gate: store-status is metadata-only and any-scope",389 sanitizeOps: false,390 sanitizeResources: false,391 fn: () =>392 withGate("ticket", async ({ gateUrl, store }) => {393 const { secret } = await store.issue("sync-only"); // sync scope suffices394 const res = await fetch(`${gateUrl}/t/${secret}/store-status`);395 assertEquals(res.status, 200);396 assertEquals(await res.json(), {397 v: 1,398 appId: "stub-app",399 schema: { deployed: true, headHash: "hash-union", permissionsHead: "perm-head-3" },400 });401 // No ticket → 404 (path shape), bogus → 401.402 const bare = await fetch(`${gateUrl}/store-status`);403 assertEquals(bare.status, 404);404 await bare.body?.cancel();405 }),406});