clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-demo-review-fixes
lofi / package / access / shared_fields_lifecycle_sync_test.ts
shared_fields_lifecycle_sync_test.ts 11.8K
1// The group field-key lifecycle end to end against the real pinned server:2// creation mints and self-wraps generation 1, an arriving member receives3// held keys (pinned out-of-band by their lofi2 fingerprint), removal rotates4// to a generation the removed member never receives — and the removed member5// keeps reading old-generation content while new content stays pending for6// them, which is exactly the documented lazy-rekey posture.78import { createDb, schema as jazz } from "jazz-tools";9import { deploy, startLocalJazzServer } from "jazz-tools/testing";10import {11 defineAccessPolicies,12 groupAccess,13 groupMembershipTable,14 groupRoleCapabilities,15 sharedFieldAccess,16 sharedFieldDirectoryTable,17 sharedFieldKeyTable,18} from "./mod.ts";19import {20 bootstrapGroupFieldKey,21 reconcileSharedFieldKeys,22 rotateGroupFieldKey,23 type SharedFieldLifecycleContext,24 wrapHeldKeysForMember,25} from "./shared-field-lifecycle.ts";26import { s } from "../schema/mod.ts";27import { type SharedFieldValue } from "../schema/mod.ts";28import { clearSharedColumnRegistry } from "../schema/shared-registry.ts";29import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";30import {31 clearSharedFieldIdentity,32 clearSharedFieldKeys,33 installSharedFieldIdentity,34} from "../schema/shared-keyring.ts";35import { sealSharedColumnValuesSync } from "../runtime/shared-field-write.ts";36import {37 clearFingerprintPins,38 deriveSharedFieldIdentity,39 ensureDirectoryEntry,40 startSharedFieldKeyWatcher,41} from "../runtime/shared-field-keys.ts";42import { assert } from "../runtime/test-assert.ts";4344const APP_ID = "lifecycle-app";45const SHARED_OPTIONS = {46 group: "workspaces",47 groupIdColumn: "workspaceId",48 keys: "workspaceFieldKeys",49 directory: "keyDirectory",50} as const;5152const app = s.defineApp({53 workspaces: jazz.table({ name: jazz.string() }),54 workspaceMembers: groupMembershipTable("workspaces"),55 workspaceFieldKeys: sharedFieldKeyTable("workspaces"),56 keyDirectory: sharedFieldDirectoryTable(),57 docs: jazz.table({58 workspaceId: jazz.ref("workspaces"),59 title: jazz.string(),60 body: s.sharedEncryptedText("lifecycle.docs.body", SHARED_OPTIONS),61 }),62});63// Docs read openly: exists-gated rows do not propagate cross-account at the64// pin (engine canary in shared_directory_sync_test.ts); content protection65// here is the sealing under test.66const permissions = defineAccessPolicies(app, [67 sharedFieldAccess({ directory: app.keyDirectory }),68 groupAccess({69 groups: app.workspaces,70 members: app.workspaceMembers,71 resources: [],72 groupId: "workspaceId",73 fieldKeys: app.workspaceFieldKeys,74 }),75], (context) => {76 context.policy.docs.allowRead.always();77 context.policy.docs.allowInsert.always();78 context.policy.docs.allowUpdate.always();79 context.policy.docs.allowDelete.always();80});8182function secret(fill: number): string {83 let binary = "";84 for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);85 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");86}8788async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {89 let timer: ReturnType<typeof setTimeout> | undefined;90 try {91 return await Promise.race([92 operation,93 new Promise<never>((_resolve, reject) => {94 timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);95 }),96 ]);97 } finally {98 if (timer !== undefined) clearTimeout(timer);99 }100}101102async function until(check: () => boolean, label: string, milliseconds = 10_000): Promise<void> {103 const deadline = Date.now() + milliseconds;104 while (!check()) {105 if (Date.now() > deadline) throw new Error(`${label} did not settle`);106 await new Promise((resolve) => setTimeout(resolve, 50));107 }108}109110Deno.test("the key lifecycle: bootstrap, member delivery, and lazy rekey", async () => {111 clearFingerprintPins(APP_ID);112 clearSharedFieldKeys();113 const server = await startLocalJazzServer({ allowLocalFirstAuth: true });114 await deploy({115 appId: server.appId,116 serverUrl: server.url,117 adminSecret: server.adminSecret,118 schema: app,119 permissions,120 });121 const alice = await createDb({122 appId: server.appId,123 serverUrl: server.url,124 secret: secret(1),125 userBranch: "main",126 driver: { type: "memory" },127 });128 const bob = await createDb({129 appId: server.appId,130 serverUrl: server.url,131 secret: secret(2),132 userBranch: "main",133 driver: { type: "memory" },134 });135 let stopWatcher: (() => void) | null = null;136 try {137 const aliceId = alice.getAuthState().session?.user_id as string;138 const bobId = bob.getAuthState().session?.user_id as string;139 const aliceIdentity = await deriveSharedFieldIdentity("alice-lifecycle-secret");140 const bobIdentity = await deriveSharedFieldIdentity("bob-lifecycle-secret");141 const aliceContext: SharedFieldLifecycleContext = {142 db: alice as never,143 appId: APP_ID,144 userId: aliceId,145 groupTable: "workspaces",146 fieldKeys: app.workspaceFieldKeys,147 directory: app.keyDirectory,148 members: app.workspaceMembers,149 };150151 // --- Phase A: Alice creates the group; bootstrap mints generation 1.152 installSharedFieldIdentity(aliceIdentity);153 const workspace = await within(154 alice.insert(app.workspaces, { name: "lifecycle" }).wait({ tier: "global" }),155 "workspace insert",156 );157 await within(158 alice.insert(app.workspaceMembers, {159 groupId: workspace.id,160 user_id: aliceId,161 ...groupRoleCapabilities("admin"),162 }).wait({ tier: "global" }),163 "alice membership",164 );165 await within(bootstrapGroupFieldKey(aliceContext, workspace.id), "bootstrap");166167 // The mutation-layer sealer picks up generation 1 transparently.168 const sealedInsert = sealSharedColumnValuesSync("docs", {169 workspaceId: workspace.id,170 title: "first",171 body: "written under generation one",172 });173 assert(174 String(sealedInsert.body).startsWith(`encs1.workspaces.${workspace.id}.1.`),175 "the sealer must use the bootstrapped generation",176 );177 const firstDoc = await within(178 alice.insert(app.docs, sealedInsert as never).wait({ tier: "global" }),179 "first doc insert",180 );181182 // --- Phase B: Bob joins; his lofi2 fingerprint pins him out-of-band and183 // Alice's device wraps generation 1 to him.184 await within(185 ensureDirectoryEntry({186 db: bob as never,187 directory: app.keyDirectory,188 userId: bobId,189 identity: bobIdentity,190 }).then(() => undefined),191 "bob publishes",192 );193 await within(194 alice.insert(app.workspaceMembers, {195 groupId: workspace.id,196 user_id: bobId,197 ...groupRoleCapabilities("writer"),198 }).wait({ tier: "global" }),199 "bob membership",200 );201 const delivery = await within(202 wrapHeldKeysForMember(aliceContext, workspace.id, bobId, bobIdentity.fingerprint),203 "wrap held keys for bob",204 );205 assert(206 delivery.wrapped === 1 && delivery.skip === null,207 `bob must receive one wrap (saw ${JSON.stringify(delivery)})`,208 );209 // Repair finds nothing missing afterwards.210 assert(211 (await within(reconcileSharedFieldKeys(aliceContext, workspace.id), "reconcile")) === 0,212 "reconcile after full delivery must be a no-op",213 );214215 // --- Phase C: Bob's device (fresh keyring) unwraps through the watcher216 // and reads generation-one content.217 clearSharedFieldKeys();218 installSharedFieldIdentity(bobIdentity);219 stopWatcher = startSharedFieldKeyWatcher({220 db: bob as never,221 appId: APP_ID,222 userId: bobId,223 configs: [{ label: "lifecycle.docs.body", kind: "text", ...SHARED_OPTIONS }],224 findTable: (name) =>225 name === "workspaceFieldKeys"226 ? app.workspaceFieldKeys227 : name === "keyDirectory"228 ? app.keyDirectory229 : null,230 onAlert: (alert) => {231 throw new Error(`unexpected watcher alert: ${alert.code} — ${alert.detail}`);232 },233 });234 let firstBody: SharedFieldValue<string> | undefined;235 await until(() => {236 void bob.all(app.docs.where({ id: firstDoc.id })).then((rows) => {237 firstBody = rows[0]?.body as unknown as SharedFieldValue<string>;238 });239 return firstBody?.state === "ready";240 }, "bob reads generation one");241 assert(242 firstBody?.state === "ready" && firstBody.value === "written under generation one",243 "bob must decrypt generation-one content",244 );245 stopWatcher();246 stopWatcher = null;247248 // --- Phase D: Bob is removed; rotation mints generation 2 wrapped only249 // to the remaining member.250 installSharedFieldIdentity(aliceIdentity);251 const bobMembership = await within(252 alice.all(app.workspaceMembers.where({ groupId: workspace.id, user_id: bobId } as never)),253 "find bob's membership",254 );255 await within(256 alice.delete(app.workspaceMembers, (bobMembership[0] as { id: string }).id)257 .wait({ tier: "global" }),258 "remove bob",259 );260 await within(rotateGroupFieldKey(aliceContext, workspace.id, bobId), "rotate");261 const wraps = await within(262 alice.all(app.workspaceFieldKeys.where({ groupId: workspace.id } as never)),263 "list wraps",264 ) as Array<{ recipient_user_id: string; generation: number }>;265 assert(266 !wraps.some((row) => row.recipient_user_id === bobId && row.generation === 2),267 "the removed member must never receive the new generation",268 );269 assert(270 wraps.some((row) => row.recipient_user_id === aliceId && row.generation === 2),271 "the remaining member must receive the new generation",272 );273274 const sealedSecond = sealSharedColumnValuesSync("docs", {275 workspaceId: workspace.id,276 title: "second",277 body: "written after the rotation",278 });279 assert(280 String(sealedSecond.body).startsWith(`encs1.workspaces.${workspace.id}.2.`),281 "post-rotation writes must seal under generation two",282 );283 const secondDoc = await within(284 alice.insert(app.docs, sealedSecond as never).wait({ tier: "global" }),285 "second doc insert",286 );287288 // --- Phase E: Bob's device holds only generation 1 — old content stays289 // readable, new content stays pending. Lazy rekey, stated honestly.290 clearSharedFieldKeys();291 installSharedFieldIdentity(bobIdentity);292 stopWatcher = startSharedFieldKeyWatcher({293 db: bob as never,294 appId: APP_ID,295 userId: bobId,296 configs: [{ label: "lifecycle.docs.body", kind: "text", ...SHARED_OPTIONS }],297 findTable: (name) =>298 name === "workspaceFieldKeys"299 ? app.workspaceFieldKeys300 : name === "keyDirectory"301 ? app.keyDirectory302 : null,303 onAlert: () => {},304 });305 let oldBody: SharedFieldValue<string> | undefined;306 await until(() => {307 void bob.all(app.docs.where({ id: firstDoc.id })).then((rows) => {308 oldBody = rows[0]?.body as unknown as SharedFieldValue<string>;309 });310 return oldBody?.state === "ready";311 }, "bob re-reads generation one");312 let newBody: SharedFieldValue<string> | undefined;313 await until(() => {314 void bob.all(app.docs.where({ id: secondDoc.id })).then((rows) => {315 newBody = rows[0]?.body as unknown as SharedFieldValue<string>;316 });317 return newBody !== undefined;318 }, "bob sees the second doc row");319 assert(320 newBody?.state === "pending-key" && newBody.generation === 2,321 `generation-two content must stay pending for the removed member ` +322 `(saw ${JSON.stringify(newBody)})`,323 );324 } finally {325 stopWatcher?.();326 clearSharedFieldIdentity();327 clearSharedFieldKeys();328 clearFingerprintPins(APP_ID);329 clearSharedColumnRegistry();330 clearEncryptedColumnRegistry();331 await Promise.allSettled([332 within(alice.logout(), "Alice cleanup", 3_000),333 within(bob.logout(), "Bob cleanup", 3_000),334 ]);335 await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);336 }337});