clone

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

feat: provision-scoped tickets, store-status preflight, gate parity (#2)

Dami committed 82ae44f
Ticket scope: optional {scope: "sync"|"provision"} on the lofisync1
format — absent means sync, so every existing ticket keeps meaning
transport-only. Provision is a strict superset: the gate injects the
node's X-Jazz-Admin-Secret for provision-scoped HTTP (on /admin/* AND
catalogue reads — the lofi#109 merge flow fetches the stored head schema
verbatim without ever holding the secret client-side); inbound admin
headers are stripped in ticket mode, so the secret only ever originates
from the node. Sync scope on admin paths gets the same 401 shape as an
invalid ticket. CLI: ticket issue --provision; list shows scope.

Store-status preflight: GET <ticket.url>/store-status (any scope) —
metadata-only {deployed, headHash, permissionsHead}, answered by the
node from its loopback Jazz. Head derives from schemas[].publishedAt:
the bare hashes array is NOT ordered (caught when the full suite ran
combined — hashes[last] flaked). This is what lets lofi's classifier
report no_schema instead of the pinned hang-on-write failure.

E2E mirrors lofi#109's validated experiment, entirely through the gate
on a placeholder client-side admin secret: slice A deploy → union merge
with s.defineMigration createTables → old-hash client keeps syncing →
union client writes both slices → sync-scoped deploy rejected. Same
provisioning through an iroh-paired leaf lands on the root. Schemas are
defined inside test fns (jazz registers schemas process-globally —
module-level definitions would clobber other files' declared hashes).

docs/app-ticket.md: scopes, injection semantics, store-status contract;
docs/fixtures/app-ticket-fixtures.json: machine-readable conformance
fixtures for the lofi-side parser (+ decode test). hosting doc §2
rewritten around runtime provisioning; operator deploy is the fallback.

34 tests green.
commit
82ae44fac7f99f33a1355c1d4b7bcbe2075901a0
parent
4793b998816168df161c6f5aa888cf441e2fb50b
author
Dami <dami.etoile@gmail.com>
11 files changed, +646 -28
Mcli.ts21++-
Mdocs/app-ticket.md54++++++-
Adocs/fixtures/app-ticket-fixtures.json45+++++
Mdocs/hosting-lofi-apps.md18++-
Msrc/appticket.ts15++-
Msrc/gate.ts119++++++++++++++-
Msrc/node.ts16++-
Mtests/appticket_test.ts19++
Mtests/convergence_test.ts26++-
Mtests/gate_test.ts90+++++++++++-
Atests/provisioning_test.ts251++++++++++++++++++++++++++++++
M cli.ts +17 -4
@@ -16,7 +16,7 @@ Usage:
1616 [--public-url <base>] [--open] [--storage-path <path>] [--memory]1717 lofi-node start [--dir <dataDir>]1818 lofi-node pair <node-ticket> [--dir <dataDir>]19- lofi-node ticket issue [--label <s>] [--url <base>] [--dir <dataDir>]19+ lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]2020 lofi-node ticket list [--dir <dataDir>]2121 lofi-node ticket revoke <id> [--dir <dataDir>]2222 lofi-node status [--dir <dataDir>]
@@ -36,6 +36,7 @@ interface Args {
3636 url?: string;3737 open: boolean;3838 memory: boolean;39+ provision: boolean;3940 storagePath?: string;4041 }4142
@@ -46,6 +47,7 @@ function parseArgs(argv: string[]): Args {
4647 dir: "./lofi-node-data",4748 open: false,4849 memory: false,50+ provision: false,4951 };5052 for (let i = 1; i < argv.length; i++) {5153 const arg = argv[i];
@@ -57,6 +59,7 @@ function parseArgs(argv: string[]): Args {
5759 else if (arg === "--url") args.url = argv[++i];5860 else if (arg === "--open") args.open = true;5961 else if (arg === "--memory") args.memory = true;62+ else if (arg === "--provision") args.provision = true;6063 else if (arg === "--storage-path") args.storagePath = argv[++i];6164 else args.positional.push(arg);6265 }
@@ -215,17 +218,25 @@ async function cmdTicket(args: Args) {
215218 );216219 }217220 }218- const { record, secret } = await store.issue(args.label);221+ const scope = args.provision ? "provision" as const : "sync" as const;222+ const { record, secret } = await store.issue(args.label, scope);219223 const ticket = encodeAppTicket({220224 v: 1,221225 appId: config.appId,222226 url: `${base.replace(/\/+$/, "")}/t/${secret}`,227+ scope: scope === "provision" ? "provision" : undefined,223228 label: args.label,224229 });225- console.log(`issued ticket ${record.id}${args.label ? ` (${args.label})` : ""}`);230+ console.log(231+ `issued ${scope} ticket ${record.id}${args.label ? ` (${args.label})` : ""}`,232+ );226233 console.log(`\n${ticket}\n`);227234 console.log("Paste this into the lofi app. The secret is NOT stored and cannot");228235 console.log("be shown again; a running node accepts it immediately.");236+ if (scope === "provision") {237+ console.log("PROVISION scope: this ticket also unlocks store administration");238+ console.log("(schema deploys) through the gate. Issue per provisioning context.");239+ }229240 return;230241 }231242
@@ -237,7 +248,9 @@ async function cmdTicket(args: Args) {
237248 }238249 for (const t of tickets) {239250 console.log(240- `${t.id} ${t.revokedAt ? "REVOKED" : "active "} ${t.createdAt} ${t.label ?? ""}`,251+ `${t.id} ${t.revokedAt ? "REVOKED" : "active "} ${252+ (t.scope ?? "sync").padEnd(9)253+ } ${t.createdAt} ${t.label ?? ""}`,241254 );242255 }243256 return;
M docs/app-ticket.md +51 -3
@@ -15,13 +15,16 @@ lofisync1.<base64url(JSON, no padding)>
1515 "v": 1,1616 "appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",1717 "url": "http://192.168.1.10:4802/t/<secret>", // ← the app's serverUrl, verbatim18+ "scope": "provision", // optional: "sync" (default when absent) | "provision"1819 "label": "phone", // optional, user-facing19- "node": "endpoint…" // optional: the node's iroh20- // EndpointTicket (forward21- // compat; unused by browsers)20+ "node": "endpoint…" // optional: the node's iroh EndpointTicket (forward compat)2221 }2322 ```2423 24+Parsers must treat unknown OPTIONAL fields as forward-compatible additions and an absent `scope` as25+`"sync"` — every pre-scope ticket keeps meaning transport-only. Machine-readable conformance26+fixtures: [fixtures/app-ticket-fixtures.json](fixtures/app-ticket-fixtures.json).27+2528 - `<secret>` is 32 random bytes base64url (43 chars, alphabet `[A-Za-z0-9_-]`) — safe as a URL path2629 segment with no encoding.2730 - The node stores only the SHA-256 digest of the secret; the ticket string is displayable **once**,
@@ -41,6 +44,51 @@ changes**:
4144 The node's access gate validates the secret (timing-safe, digest vs digest), strips the4245 `/t/<secret>` prefix, and proxies to the internal Jazz server (which binds loopback-only).4346 47+## Scopes: sync vs provision48+49+- **`sync`** (default): transport only. Admin/catalogue-mutating routes (`…/apps/<id>/admin/…`)50+ answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate. This51+ is what lofi#109 calls "enrolling a ticket attaches transport only and never mutates the store."52+- **`provision`**: a strict superset of sync — everything above PLUS store administration. For53+ provision-scoped HTTP requests the gate **injects the node's `X-Jazz-Admin-Secret` itself** (on54+ `/admin/*` and on catalogue reads like `/schemas` and `/schema/<hash>`, which the merge-deploy55+ flow needs to fetch the stored head schema verbatim). The admin secret therefore never leaves the56+ node and never transits the client: a provisioning client passes any placeholder admin secret to57+ jazz-tools `deploy` — the gate strips inbound `X-Jazz-Admin-Secret` headers in ticket mode and58+ substitutes its own. Possession of a provision ticket IS the store-administration opt-in. Issue59+ one per provisioning context (`ticket issue --provision --label60+ laptop-admin`), not per device.61+62+Revocation semantics are identical for both scopes (401 / WS 4001).63+64+## Store-status preflight65+66+Against a store with **no deployed schema, client writes hang indefinitely** (lofi#109's pinned67+failure surface) — so a sync-only client needs a preflight it can reach without the admin secret.68+Any valid ticket (sync scope included) may call:69+70+```71+GET <ticket.url>/store-status72+```73+74+```jsonc75+{76+ "v": 1,77+ "appId": "…",78+ "schema": {79+ "deployed": true, // false → { "deployed": false } only80+ "headHash": "ff85ac…", // newest stored schema hash81+ "permissionsHead": "0195…" // current permissions head object id, or null82+ }83+}84+```85+86+Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it87+holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to88+`no_schema` / hash-comparison states instead of hanging; `502 {"error":"store_unavailable"}` means89+the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev90+setups hold the admin secret and can query Jazz directly.91+4492 ## Enrollment flow (app side)4593 4694 1. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`
A docs/fixtures/app-ticket-fixtures.json +45 -0
@@ -0,0 +1,45 @@
1+{2+ "comment": "Cross-repo contract fixtures for the lofisync1 app-ticket format. The lofi side's parser must accept `valid` entries with the listed expectations and reject every `invalid` entry. Secrets here are fixed placeholders, never issued by any node.",3+ "valid": [4+ {5+ "name": "sync ticket, no scope field (default sync)",6+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjExMTExMTExLTExMTEtNDExMS04MTExLTExMTExMTExMTExMSIsInVybCI6Imh0dHA6Ly8xOTIuMTY4LjEuMTA6NDgwMi90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJsYWJlbCI6InBob25lIn0",7+ "expect": {8+ "appId": "11111111-1111-4111-8111-111111111111",9+ "scope": "sync",10+ "url": "http://192.168.1.10:4802/t/SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS"11+ }12+ },13+ {14+ "name": "provision ticket with node field",15+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IjIyMjIyMjIyLTIyMjItNDIyMi04MjIyLTIyMjIyMjIyMjIyMiIsInVybCI6Imh0dHBzOi8vbm9kZS5leGFtcGxlLmNvbS90L1BQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFAiLCJzY29wZSI6InByb3Zpc2lvbiIsImxhYmVsIjoibGFwdG9wLWFkbWluIiwibm9kZSI6ImVuZHBvaW50ZXhhbXBsZWV4YW1wbGVleGFtcGxlZXhhbXBsZWV4YW1wbGVleGFtcGxlIn0",16+ "expect": {17+ "appId": "22222222-2222-4222-8222-222222222222",18+ "scope": "provision",19+ "url": "https://node.example.com/t/PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP"20+ }21+ }22+ ],23+ "invalid": [24+ {25+ "name": "wrong prefix",26+ "ticket": "endpointabcdefabcdefabcdefabcdefabcdef"27+ },28+ {29+ "name": "empty payload",30+ "ticket": "lofisync1."31+ },32+ {33+ "name": "ws url",34+ "ticket": "lofisync1.eyJ2IjoxLCcorrupted"35+ },36+ {37+ "name": "bad secret path",38+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC93cm9uZyJ9"39+ },40+ {41+ "name": "unknown scope",42+ "ticket": "lofisync1.eyJ2IjoxLCJhcHBJZCI6IngiLCJ1cmwiOiJodHRwOi8vaC90L1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1MiLCJzY29wZSI6InJvb3QifQ=="43+ }44+ ]45+}
M docs/hosting-lofi-apps.md +14 -4
@@ -37,11 +37,21 @@ The user pastes the ticket into their lofi app; the app stores it passkey-encryp
3737 and uses the ticket's URL as its sync server. Format and app-side flow:3838 [app-ticket.md](app-ticket.md).3939 40-## 2. Deploy the app's schema to the node40+## 2. Get the app's schema into the store4141 42-A lofi app's schema must be published to the sync server it uses (the managed dev server does this43-automatically; a self-hosted node needs it once — and on every schema change). From the app44-directory, use the same schema-project deploy the lofi dev flow uses:42+A store with no deployed schema is unusable — client writes hang (preflight with43+`GET <ticket.url>/store-status`, see [app-ticket.md](app-ticket.md)). Two paths publish a schema:44+45+**Primary — runtime provisioning via a provision ticket** (the lofi#109 opt-in flow): issue46+`lofi-node ticket issue --provision --label app-setup` and hand it to the app. The app (or any47+jazz-tools `deploy` caller) uses the ticket URL as `serverUrl` with a placeholder admin secret — the48+gate strips inbound admin headers and injects the node's own for provision-scoped requests, so the49+real secret never leaves the node. Slice-by-slice merge deploys (`createTables` migrations, union50+permissions) flow through the same ticket; old-hash clients keep syncing across a merge.51+52+**Fallback — operator-side deploy from the app directory**, using the same schema-project deploy the53+lofi dev flow uses (also easiest via a provision ticket URL as `serverUrl`; the admin secret54+argument can then be a placeholder):4555 4656 ```ts4757 // deploy-schema.ts (run: deno run -A deploy-schema.ts)
M src/appticket.ts +14 -1
@@ -22,6 +22,11 @@ export interface AppTicket {
2222 * Used VERBATIM as the lofi app's serverUrl — jazz clients preserve base2323 * paths, so the secret rides every WS connect and catalogue fetch. */2424 url: string;25+ /** "sync" (default when absent — every pre-scope ticket keeps meaning26+ * transport-only) or "provision": transport PLUS store administration (the27+ * gate injects the node's admin secret for catalogue/admin routes).28+ * Provision is a strict superset of sync. */29+ scope?: "sync" | "provision";2530 label?: string;2631 /** The node's iroh EndpointTicket (forward compat; unused by browsers). */2732 node?: string;
@@ -29,6 +34,7 @@ export interface AppTicket {
2934 3035 export interface AppTicketRecord {3136 id: string;37+ scope?: "sync" | "provision";3238 label?: string;3339 secretHash: string;3440 createdAt: string;
@@ -66,6 +72,9 @@ export function decodeAppTicket(text: string): AppTicket | null {
6672 if (parsed.v !== 1 || typeof parsed.appId !== "string" || typeof parsed.url !== "string") {6773 return null;6874 }75+ if (parsed.scope !== undefined && parsed.scope !== "sync" && parsed.scope !== "provision") {76+ return null;77+ }6978 const url = new URL(parsed.url);7079 if (!/^https?:$/.test(url.protocol) || !/^\/t\/[A-Za-z0-9_-]{43}$/.test(url.pathname)) {7180 return null;
@@ -158,12 +167,16 @@ export class AppTicketStore {
158167 this.#mtime = (await Deno.stat(this.#path)).mtime?.getTime() ?? 0;159168 }160169 161- async issue(label?: string): Promise<{ record: AppTicketRecord; secret: string }> {170+ async issue(171+ label?: string,172+ scope: "sync" | "provision" = "sync",173+ ): Promise<{ record: AppTicketRecord; secret: string }> {162174 await this.#reload();163175 const secret = generateSecret();164176 const secretHash = await hashSecret(secret);165177 const record: AppTicketRecord = {166178 id: secretHash.slice(0, 12),179+ scope,167180 label,168181 secretHash,169182 createdAt: new Date().toISOString(),
M src/gate.ts +114 -5
@@ -31,6 +31,12 @@ export interface GateOptions {
3131 target: () => string;3232 mode: "open" | "ticket";3333 store: AppTicketStore;34+ /** The node's Jazz app id + admin secret, for provision-scoped injection35+ * and the store-status endpoint. The secret never transits the gate inbound36+ * — client-supplied X-Jazz-Admin-Secret headers are stripped in ticket37+ * mode; the gate injects config's own for provision-scoped requests. */38+ appId: string;39+ adminSecret: string;3440 }3541 3642 export interface Gate {
@@ -90,11 +96,21 @@ export function pumpWebSockets(
9096 upstream.addEventListener("error", () => teardown(1011, "upstream ws error"));9197 }9298 93-async function proxyHttp(req: Request, targetBase: string, path: string): Promise<Response> {99+async function proxyHttp(100+ req: Request,101+ targetBase: string,102+ path: string,103+ headerOverrides?: Record<string, string | null>,104+): Promise<Response> {94105 try {106+ const headers = forwardableHeaders(req.headers);107+ for (const [name, value] of Object.entries(headerOverrides ?? {})) {108+ if (value === null) delete headers[name];109+ else headers[name] = value;110+ }95111 const res = await fetch(new URL(path, targetBase), {96112 method: req.method,97- headers: forwardableHeaders(req.headers),113+ headers,98114 body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,99115 });100116 return new Response(res.body, {
@@ -107,6 +123,77 @@ async function proxyHttp(req: Request, targetBase: string, path: string): Promis
107123 }108124 }109125 126+/** Node-served, metadata-only store preflight: lets a sync-scoped client127+ * classify `no_schema` / hash-mismatch instead of hanging on writes (lofi#109128+ * failure surface). Never returns schema contents, policies, or secrets. */129+async function storeStatus(130+ targetBase: string,131+ appId: string,132+ adminSecret: string,133+): Promise<Response> {134+ try {135+ const admin = { "X-Jazz-Admin-Secret": adminSecret };136+ const schemasRes = await fetch(new URL(`/apps/${appId}/schemas`, targetBase), {137+ headers: admin,138+ });139+ if (!schemasRes.ok) {140+ await schemasRes.body?.cancel();141+ debug("store-status schemas fetch", schemasRes.status);142+ return new Response(JSON.stringify({ error: "store_unavailable" }), {143+ status: 502,144+ headers: { "content-type": "application/json" },145+ });146+ }147+ const body = await schemasRes.json() as {148+ hashes?: string[];149+ schemas?: { hash: string; publishedAt?: number | string }[];150+ };151+ // Head = newest published schema; the bare `hashes` array is NOT ordered.152+ const entries = body.schemas ?? [];153+ let headHash: string | undefined;154+ let newest = -Infinity;155+ for (const entry of entries) {156+ const at = typeof entry.publishedAt === "string"157+ ? Date.parse(entry.publishedAt)158+ : entry.publishedAt ?? 0;159+ if (at >= newest) {160+ newest = at;161+ headHash = entry.hash;162+ }163+ }164+ const hashes = body.hashes ?? entries.map((e) => e.hash);165+ headHash ??= hashes[hashes.length - 1];166+ let permissionsHead: string | null = null;167+ try {168+ const headRes = await fetch(169+ new URL(`/apps/${appId}/admin/permissions/head`, targetBase),170+ { headers: admin },171+ );172+ if (headRes.ok) {173+ permissionsHead = ((await headRes.json()) as { head?: string | null }).head ?? null;174+ } else {175+ await headRes.body?.cancel();176+ }177+ } catch {178+ // permissions head stays null179+ }180+ const schema = hashes.length > 0181+ ? { deployed: true, headHash, permissionsHead }182+ : { deployed: false };183+ return new Response(JSON.stringify({ v: 1, appId, schema }), {184+ headers: { "content-type": "application/json" },185+ });186+ } catch (e) {187+ debug("store-status error", (e as Error).message);188+ return new Response(JSON.stringify({ error: "store_unavailable" }), {189+ status: 502,190+ headers: { "content-type": "application/json" },191+ });192+ }193+}194+195+const ADMIN_PATH = /^\/apps\/[^/]+\/admin(\/|$)/;196+110197 function proxyWebSocket(req: Request, targetBase: string, path: string): {111198 response: Response;112199 socket: WebSocket;
@@ -184,6 +271,11 @@ export function startGate(options: GateOptions): Gate {
184271 const targetBase = options.target();185272 186273 if (options.mode === "open") {274+ // Open nodes still serve the preflight so lofi's store classifier275+ // works against dev setups.276+ if (url.pathname === "/store-status" && !isUpgrade) {277+ return storeStatus(targetBase, options.appId, options.adminSecret);278+ }187279 const path = url.pathname + url.search;188280 return isUpgrade189281 ? proxyWebSocket(req, targetBase, path).response
@@ -202,14 +294,31 @@ export function startGate(options: GateOptions): Gate {
202294 debug("rejected", verdict.status, url.pathname.slice(0, 24));203295 return unauthorized();204296 }205- const path = (rest || "/") + url.search;206- debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id);297+ const scope = verdict.record.scope ?? "sync";298+ const strippedPath = rest || "/";299+ if (strippedPath === "/store-status" && !isUpgrade) {300+ return storeStatus(targetBase, options.appId, options.adminSecret);301+ }302+ // Admin/catalogue-mutating routes need provision scope; a sync ticket303+ // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).304+ if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {305+ debug("rejected sync-scope on admin path", verdict.record.id);306+ return unauthorized();307+ }308+ const path = strippedPath + url.search;309+ debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id, scope);207310 if (isUpgrade) {208311 const { response, socket } = proxyWebSocket(req, targetBase, path);209312 track(verdict.record.id, socket);210313 return response;211314 }212- return proxyHttp(req, targetBase, path);315+ // The admin secret only ever originates from the node: inbound headers316+ // are stripped; provision-scoped requests get config's secret injected317+ // (on catalogue reads too — the merge flow fetches the head schema318+ // verbatim without ever holding the secret client-side).319+ return proxyHttp(req, targetBase, path, {320+ "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,321+ });213322 },214323 );215324
M src/node.ts +13 -3
@@ -57,6 +57,7 @@ export type MeshStatus =
5757 5858 export interface AppTicketInfo {5959 id: string;60+ scope: "sync" | "provision";6061 label?: string;6162 createdAt: string;6263 revoked: boolean;
@@ -80,8 +81,12 @@ export interface SyncNode {
8081 /** Node-pairing ticket; throws MeshUnavailableError if mesh is down. */8182 ticket(): string;8283 /** Issue an app-connect ticket (ticket mode only). The returned string is83- * shown once — the secret is never stored, only its digest. */84- issueTicket(options?: { label?: string; publicBase?: string }): Promise<{84+ * shown once — the secret is never stored, only its digest. scope85+ * "provision" additionally unlocks store administration through the gate86+ * (a strict superset of sync). */87+ issueTicket(88+ options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" },89+ ): Promise<{8590 id: string;8691 ticket: string;8792 }>;
@@ -99,6 +104,7 @@ export interface SyncNode {
99104 function toTicketInfo(record: AppTicketRecord): AppTicketInfo {100105 return {101106 id: record.id,107+ scope: record.scope ?? "sync",102108 label: record.label,103109 createdAt: record.createdAt,104110 revoked: Boolean(record.revokedAt),
@@ -226,6 +232,8 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
226232 target: () => jazz.url.replace(/^ws/, "http"),227233 mode: "ticket",228234 store: ticketStore,235+ appId: options.appId,236+ adminSecret: options.adminSecret,229237 });230238 }231239
@@ -263,13 +271,15 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
263271 },264272 issueTicket: async (ticketOptions = {}) => {265273 if (!gate) throw new Error('issueTicket requires access: "ticket"');266- const { record, secret } = await ticketStore.issue(ticketOptions.label);274+ const scope = ticketOptions.scope ?? "sync";275+ const { record, secret } = await ticketStore.issue(ticketOptions.label, scope);267276 const base = (ticketOptions.publicBase ?? options.publicUrl ??268277 `http://127.0.0.1:${gate.port}`).replace(/\/+$/, "");269278 const ticket = encodeAppTicket({270279 v: 1,271280 appId: options.appId,272281 url: `${base}/t/${secret}`,282+ scope: scope === "provision" ? "provision" : undefined,273283 label: ticketOptions.label,274284 node: ticketString ?? undefined,275285 });
M tests/appticket_test.ts +19 -0
M tests/convergence_test.ts +20 -6
@@ -239,20 +239,31 @@ Deno.test({
239239 access: "ticket",240240 });241241 try {242+ // Provision ticket for the deploy (the gate injects the node's admin243+ // secret — the client passes a placeholder), sync ticket for devices.244+ const provisionTicket = decodeAppTicket(245+ (await node.issueTicket({ label: "setup", scope: "provision" })).ticket,246+ );242247 const issued = await node.issueTicket({ label: "fixture" });243248 const parsed = decodeAppTicket(issued.ticket);244- assert(parsed !== null, "issued ticket decodes");249+ assert(parsed !== null && provisionTicket !== null, "issued tickets decode");245250 const gatedUrl = parsed.url; // http://127.0.0.1:<gatePort>/t/<secret>246251 assertEquals(parsed.appId, node.appId);247252 248- // ZERO client changes: the ticket URL is the serverUrl for BOTH the249- // admin deploy (catalogue HTTP through the gate) and the devices.253+ // ZERO client changes: ticket URLs are the serverUrl for BOTH the254+ // deploy (provision scope, placeholder secret) and the devices (sync).255+ await deploy({256+ serverUrl: provisionTicket.url,257+ appId: node.appId,258+ adminSecret: "placeholder-the-gate-injects-the-real-one",259+ schema: app,260+ permissions,261+ });250262 const target = {251263 serverUrl: gatedUrl,252264 appId: node.appId,253265 adminSecret: "lofi_admin_gate",254266 };255- await deploy({ ...target, schema: app, permissions });256267 const [deviceA, deviceB] = await connectDevices(target, target);257268 try {258269 await runConvergenceFixture(deviceA, deviceB);
@@ -272,8 +283,11 @@ Deno.test({
272283 await res.body?.cancel();273284 assertEquals(res.status, 401, "revoked ticket → 401 at the gate");274285 const tickets = await node.listTickets();275- assertEquals(tickets.length, 1);276- assert(tickets[0].revoked, "status reflects revocation");286+ assertEquals(tickets.length, 2);287+ assert(288+ tickets.find((t) => t.id === issued.id)?.revoked,289+ "status reflects revocation",290+ );277291 } finally {278292 await node.stop();279293 }
M tests/gate_test.ts +88 -2
@@ -10,6 +10,27 @@ import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";
1010 function startStubUpstream() {1111 const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {1212 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+ }1334 if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {1435 const protocol = req.headers.get("sec-websocket-protocol")?.split(",")[0].trim();1536 const { socket, response } = Deno.upgradeWebSocket(req, protocol ? { protocol } : {});
@@ -52,6 +73,8 @@ async function withGate(
5273 target: () => upstream.url,5374 mode,5475 store,76+ appId: "stub-app",77+ adminSecret: "node-admin-secret",5578 });5679 try {5780 await fn({ gateUrl: `http://127.0.0.1:${gate.port}`, store, gate });
@@ -67,7 +90,7 @@ Deno.test({
6790 sanitizeResources: false,6891 fn: () =>6992 withGate("ticket", async ({ gateUrl, store }) => {70- const { secret } = await store.issue("t");93+ const { secret } = await store.issue("t", "provision");7194 const res = await fetch(7295 `${gateUrl}/t/${secret}/apps/abc/admin/schema-connectivity?appId=xyz`,7396 { headers: { "X-Jazz-Admin-Secret": "adm" } },
@@ -75,7 +98,7 @@ Deno.test({
7598 assertEquals(res.status, 200);7699 const body = await res.json();77100 assertEquals(body.path, "/apps/abc/admin/schema-connectivity?appId=xyz");78- assertEquals(body.admin, "adm", "admin header forwarded for Jazz's own enforcement");101+ assertEquals(body.admin, "node-admin-secret", "node injects its own admin secret");79102 }),80103 });81104
@@ -175,3 +198,66 @@ Deno.test({
175198 assertEquals((await res.json()).path, "/apps/abc/schemas?x=1");176199 }),177200 });201+202+Deno.test({203+ name: "gate: sync scope on admin paths → 401 with the invalid-ticket shape",204+ sanitizeOps: false,205+ sanitizeResources: false,206+ fn: () =>207+ withGate("ticket", async ({ gateUrl, store }) => {208+ const { secret } = await store.issue("sync-only"); // default scope209+ const res = await fetch(`${gateUrl}/t/${secret}/apps/stub-app/admin/schemas`, {210+ method: "POST",211+ body: "{}",212+ });213+ assertEquals(res.status, 401);214+ assertEquals(await res.json(), { error: "invalid_ticket" }, "same shape as invalid");215+ // Non-admin paths still work for the same ticket.216+ const ok = await fetch(`${gateUrl}/t/${secret}/apps/abc/anything`);217+ assertEquals(ok.status, 200);218+ await ok.body?.cancel();219+ }),220+});221+222+Deno.test({223+ name: "gate: provision scope injects the node admin secret, strips inbound",224+ sanitizeOps: false,225+ sanitizeResources: false,226+ fn: () =>227+ withGate("ticket", async ({ gateUrl, store }) => {228+ const { secret } = await store.issue("admin", "provision");229+ // The stub echoes the admin header it received; the client sends a FAKE230+ // one which must be replaced by the node's own.231+ const res = await fetch(`${gateUrl}/t/${secret}/apps/abc/echo`, {232+ headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },233+ });234+ assertEquals((await res.json()).admin, "node-admin-secret", "injected, not forwarded");235+ // Sync scope: inbound admin header is stripped, nothing injected.236+ const { secret: syncSecret } = await store.issue("plain");237+ const res2 = await fetch(`${gateUrl}/t/${syncSecret}/apps/abc/echo`, {238+ headers: { "X-Jazz-Admin-Secret": "client-supplied-fake" },239+ });240+ assertEquals((await res2.json()).admin, null, "stripped for sync scope");241+ }),242+});243+244+Deno.test({245+ name: "gate: store-status is metadata-only and any-scope",246+ sanitizeOps: false,247+ sanitizeResources: false,248+ fn: () =>249+ withGate("ticket", async ({ gateUrl, store }) => {250+ const { secret } = await store.issue("sync-only"); // sync scope suffices251+ const res = await fetch(`${gateUrl}/t/${secret}/store-status`);252+ assertEquals(res.status, 200);253+ assertEquals(await res.json(), {254+ v: 1,255+ appId: "stub-app",256+ schema: { deployed: true, headHash: "hash-union", permissionsHead: "perm-head-3" },257+ });258+ // No ticket → 404 (path shape), bogus → 401.259+ const bare = await fetch(`${gateUrl}/store-status`);260+ assertEquals(bare.status, 404);261+ await bare.body?.cancel();262+ }),263+});
A tests/provisioning_test.ts +251 -0
@@ -0,0 +1,251 @@
1+// E2E for issue #2 §3: the lofi#109 slice-merge provisioning flow carried2+// entirely through the gate on provision-scoped tickets — the client NEVER3+// holds the admin secret (it sends a dummy; the gate strips and injects).4+//5+// Mirrors the validated direct-server experiment: deploy slice A → union6+// (A+B) with a createTables migration → old-hash client keeps syncing, the7+// union client uses both slices. Store-status classifies before/after.8+//9+// Schemas are defined INSIDE test fns: jazz registers schemas process-10+// globally at definition time, and module-level definitions here would11+// clobber the declared hash of other test files' clients.12+13+import { assert, assertEquals } from "@std/assert";14+import { createDb, schema as s } from "jazz-tools";15+import { deploy } from "jazz-tools/testing";16+import { definePermissions } from "jazz-tools/permissions";17+import { createSyncNode } from "../src/node.ts";18+import { decodeAppTicket } from "../src/appticket.ts";19+import { resolveIrohLib } from "../src/native/loader.ts";20+21+const DUMMY_ADMIN = "client-never-holds-the-real-secret";22+23+function accountSecret(fill: number): string {24+ let binary = "";25+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);26+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");27+}28+29+async function within<T>(operation: Promise<T>, label: string, milliseconds = 15_000): Promise<T> {30+ let timer: ReturnType<typeof setTimeout> | undefined;31+ try {32+ return await Promise.race([33+ operation,34+ new Promise<never>((_resolve, reject) => {35+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);36+ }),37+ ]);38+ } finally {39+ if (timer !== undefined) clearTimeout(timer);40+ }41+}42+43+async function fetchStoreStatus(ticketUrl: string) {44+ const res = await fetch(`${ticketUrl}/store-status`);45+ assertEquals(res.status, 200, "store-status reachable");46+ return await res.json() as {47+ v: 1;48+ appId: string;49+ schema: { deployed: boolean; headHash?: string; permissionsHead?: string | null };50+ };51+}52+53+Deno.test({54+ name: "provisioning: slice A → union merge with createTables, all through the gate",55+ sanitizeOps: false,56+ sanitizeResources: false,57+ fn: async () => {58+ const node = await createSyncNode({59+ appId: crypto.randomUUID(),60+ backendSecret: "lofi_backend_prov",61+ adminSecret: "real-node-admin-secret",62+ inMemory: true,63+ mesh: "off",64+ access: "ticket",65+ });66+ try {67+ const provision = decodeAppTicket(68+ (await node.issueTicket({ label: "provisioner", scope: "provision" })).ticket,69+ )!;70+ const sync = decodeAppTicket((await node.issueTicket({ label: "device" })).ticket)!;71+ assertEquals(provision.scope, "provision");72+ assertEquals(sync.scope, undefined, "sync scope omitted from the string (default)");73+74+ // Preflight against the EMPTY store: this is the state where writes75+ // would hang; the classifier must see no_schema. Sync scope suffices.76+ const before = await fetchStoreStatus(sync.url);77+ assertEquals(before.schema, { deployed: false });78+79+ // ---- Slice A deploys through the PROVISION ticket, dummy admin. ----80+ const sliceATables = {81+ aapp__items: s.table({ text: s.string() }),82+ };83+ const appA = s.defineApp(sliceATables);84+ const permsA = definePermissions(appA, ({ policy, session }) => [85+ policy.aapp__items.allowRead.where({ $createdBy: session.userId }),86+ policy.aapp__items.allowInsert.where({ $createdBy: session.userId }),87+ ]);88+ const resultA = await deploy({89+ appId: node.appId,90+ serverUrl: provision.url,91+ adminSecret: DUMMY_ADMIN,92+ schema: appA,93+ permissions: permsA,94+ });95+ const hashA = (resultA as { schema: { hash: string } }).schema.hash;96+97+ const afterA = await fetchStoreStatus(sync.url);98+ assertEquals(afterA.schema.deployed, true);99+ assertEquals(afterA.schema.headHash, hashA, "head hash tracks slice A");100+ assert(afterA.schema.permissionsHead, "permissions head present");101+102+ // Old-hash client boots NOW (declared schema = slice A) over the SYNC103+ // ticket and writes.104+ const clientA = await createDb({105+ appId: node.appId,106+ serverUrl: sync.url,107+ secret: accountSecret(3),108+ userBranch: "main",109+ driver: { type: "memory" },110+ });111+ try {112+ await within(113+ clientA.insert(appA.aapp__items, { text: "pre-merge write" }).wait({ tier: "global" }),114+ "slice-A client write before merge",115+ );116+117+ // ---- App B joins the occupied store: union + createTables. ----118+ const unionTables = {119+ ...sliceATables,120+ bapp__notes: s.table({ body: s.string() }),121+ };122+ const appUnion = s.defineApp(unionTables);123+ const permsUnion = definePermissions(appUnion, ({ policy, session }) => [124+ policy.aapp__items.allowRead.where({ $createdBy: session.userId }),125+ policy.aapp__items.allowInsert.where({ $createdBy: session.userId }),126+ policy.bapp__notes.allowRead.where({ $createdBy: session.userId }),127+ policy.bapp__notes.allowInsert.where({ $createdBy: session.userId }),128+ ]);129+ const migration = s.defineMigration({130+ from: sliceATables,131+ to: unionTables,132+ createTables: { bapp__notes: true },133+ });134+ const resultUnion = await deploy({135+ appId: node.appId,136+ serverUrl: provision.url,137+ adminSecret: DUMMY_ADMIN,138+ schema: appUnion,139+ permissions: permsUnion,140+ migration,141+ });142+ const hashUnion = (resultUnion as { schema: { hash: string } }).schema.hash;143+ assert(hashUnion !== hashA, "union hash advanced");144+145+ const afterUnion = await fetchStoreStatus(sync.url);146+ assertEquals(afterUnion.schema.headHash, hashUnion, "head hash tracks the union");147+148+ // Old-hash client CONTINUITY: clientA (declared hash A) keeps149+ // syncing untouched after the merge — the lofi#109 invariant.150+ await within(151+ clientA.insert(appA.aapp__items, { text: "post-merge write" }).wait({ tier: "global" }),152+ "slice-A client write AFTER merge (old-hash continuity)",153+ );154+155+ // Union client uses BOTH slices through the sync ticket.156+ const clientUnion = await createDb({157+ appId: node.appId,158+ serverUrl: sync.url,159+ secret: accountSecret(4),160+ userBranch: "main",161+ driver: { type: "memory" },162+ });163+ try {164+ await within(165+ clientUnion.insert(appUnion.bapp__notes, { body: "new slice works" })166+ .wait({ tier: "global" }),167+ "union client writes the new slice",168+ );169+ await within(170+ clientUnion.insert(appUnion.aapp__items, { text: "old slice too" })171+ .wait({ tier: "global" }),172+ "union client writes the original slice",173+ );174+ } finally {175+ await within(clientUnion.logout(), "union client cleanup", 3000).catch(() => {});176+ }177+ } finally {178+ await within(clientA.logout(), "slice-A client cleanup", 3000).catch(() => {});179+ }180+181+ // ---- Negative: the SYNC ticket cannot deploy (401 at the gate). ----182+ let syncDeployFailed = false;183+ try {184+ await deploy({185+ appId: node.appId,186+ serverUrl: sync.url,187+ adminSecret: "even-the-real-one-would-not-help",188+ schema: appA,189+ permissions: permsA,190+ });191+ } catch {192+ syncDeployFailed = true;193+ }194+ assert(syncDeployFailed, "sync-scoped ticket must not provision");195+ } finally {196+ await node.stop();197+ }198+ },199+});200+201+Deno.test({202+ name: "provisioning: through an iroh-paired leaf lands on the root",203+ ignore: resolveIrohLib().status !== "ok",204+ sanitizeOps: false,205+ sanitizeResources: false,206+ fn: async () => {207+ const shared = {208+ appId: crypto.randomUUID(),209+ backendSecret: "lofi_backend_leafprov",210+ adminSecret: "leafprov-admin-secret",211+ };212+ const root = await createSyncNode({ ...shared, inMemory: true, access: "ticket" });213+ const leaf = await createSyncNode({214+ ...shared,215+ inMemory: true,216+ access: "ticket",217+ upstream: { peer: root.ticket() },218+ });219+ try {220+ const leafProvision = decodeAppTicket(221+ (await leaf.issueTicket({ label: "leaf-prov", scope: "provision" })).ticket,222+ )!;223+ const rootSync = decodeAppTicket((await root.issueTicket({ label: "root-dev" })).ticket)!;224+225+ const tables = { leafapp__rows: s.table({ text: s.string() }) };226+ const app = s.defineApp(tables);227+ const perms = definePermissions(app, ({ policy, session }) => [228+ policy.leafapp__rows.allowRead.where({ $createdBy: session.userId }),229+ policy.leafapp__rows.allowInsert.where({ $createdBy: session.userId }),230+ ]);231+ // Deploy THROUGH the leaf's gate; the leaf's Jazz proxies catalogue232+ // writes to its upstream over the iroh tunnel → lands on the root.233+ const result = await deploy({234+ appId: shared.appId,235+ serverUrl: leafProvision.url,236+ adminSecret: DUMMY_ADMIN,237+ schema: app,238+ permissions: perms,239+ });240+ const hash = (result as { schema: { hash: string } }).schema.hash;241+242+ // The ROOT's store-status sees the schema deployed via the leaf.243+ const rootStatus = await fetchStoreStatus(rootSync.url);244+ assertEquals(rootStatus.schema.deployed, true, "root store has the schema");245+ assertEquals(rootStatus.schema.headHash, hash, "root head hash matches leaf deploy");246+ } finally {247+ await leaf.stop();248+ await root.stop();249+ }250+ },251+});