clone

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

feat/write-verdict-foundations
lofi-node / tests / provisioning_test.ts
provisioning_test.ts 9.6K
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.1213import { assert, assertEquals } from "@std/assert";14import { createDb, schema as s } from "jazz-tools";15import { deploy } from "jazz-tools/testing";16import { definePermissions } from "jazz-tools/permissions";17import { createSyncNode } from "../src/node.ts";18import { decodeAppTicket } from "../src/appticket.ts";19import { resolveIrohLib } from "../src/native/loader.ts";2021const DUMMY_ADMIN = "client-never-holds-the-real-secret";2223function 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}2829async 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}4243async 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}5253Deno.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)");7374 // 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 });7879 // ---- 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;9697 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");101102 // 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 );116117 // ---- 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");144145 const afterUnion = await fetchStoreStatus(sync.url);146 assertEquals(afterUnion.schema.headHash, hashUnion, "head hash tracks the union");147148 // 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 );154155 // 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 }180181 // ---- 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});200201Deno.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)!;224225 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;241242 // 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});