clone

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

feat/141-effect-library
lofi / package / access / shared_fields_read_sync_test.ts
shared_fields_read_sync_test.ts 9.3K
1// The shared-field read side, end to end against the real pinned server:2// Alice seals a value under a group field key and wraps the key to Bob; the3// real watcher on Bob's client verifies the sender through the pin store,4// unwraps, installs — and Bob's read of the same row transitions from5// pending-key to ready with no redefinition.67import { createDb, schema as jazz } from "jazz-tools";8import { deploy, startLocalJazzServer } from "jazz-tools/testing";9import {10 defineAccessPolicies,11 groupAccess,12 groupMembershipTable,13 groupRoleCapabilities,14 sharedFieldAccess,15 sharedFieldDirectoryTable,16 sharedFieldKeyTable,17} from "./mod.ts";18import { s } from "../schema/mod.ts";19import { sharedFieldReady, type SharedFieldValue } from "../schema/mod.ts";20import { clearSharedColumnRegistry } from "../schema/shared-registry.ts";21import { generateFieldKey, sealSharedValue, wrapFieldKey } from "../schema/shared-crypto.ts";22import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";23import {24 clearSharedFieldIdentity,25 clearSharedFieldKeys,26 installSharedFieldIdentity,27} from "../schema/shared-keyring.ts";28import {29 clearFingerprintPins,30 deriveSharedFieldIdentity,31 ensureDirectoryEntry,32 startSharedFieldKeyWatcher,33} from "../runtime/shared-field-keys.ts";34import { assert } from "../runtime/test-assert.ts";3536const SHARED_OPTIONS = {37 group: "workspaces",38 groupIdColumn: "workspaceId",39 keys: "workspaceFieldKeys",40 directory: "keyDirectory",41} as const;4243const app = jazz.defineApp({44 workspaces: jazz.table({ name: jazz.string() }),45 workspaceMembers: groupMembershipTable("workspaces"),46 workspaceFieldKeys: sharedFieldKeyTable("workspaces"),47 keyDirectory: sharedFieldDirectoryTable(),48 docs: jazz.table({49 workspaceId: jazz.ref("workspaces"),50 title: jazz.string(),51 body: s.sharedEncryptedText("readsync.docs.body", SHARED_OPTIONS),52 }),53});54// The docs table is deliberately NOT a groupAccess resource: on the pinned55// alpha.53 server, rows gated by an exists-based read condition never56// propagate to other accounts over sync (pinned by the engine canary in57// shared_directory_sync_test.ts), so the demo table reads openly and the58// CONTENT protection under test here is the encryption — which is the59// threat-model posture anyway: policies bound identities, sealing bounds60// the server.61const permissions = defineAccessPolicies(app, [62 sharedFieldAccess({ directory: app.keyDirectory }),63 groupAccess({64 groups: app.workspaces,65 members: app.workspaceMembers,66 resources: [],67 groupId: "workspaceId",68 fieldKeys: app.workspaceFieldKeys,69 }),70], (context) => {71 context.policy.docs.allowRead.always();72 context.policy.docs.allowInsert.always();73 context.policy.docs.allowUpdate.always();74 context.policy.docs.allowDelete.always();75});7677function secret(fill: number): string {78 let binary = "";79 for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);80 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");81}8283async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {84 let timer: ReturnType<typeof setTimeout> | undefined;85 try {86 return await Promise.race([87 operation,88 new Promise<never>((_resolve, reject) => {89 timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);90 }),91 ]);92 } finally {93 if (timer !== undefined) clearTimeout(timer);94 }95}9697async function until(check: () => boolean, label: string, milliseconds = 10_000): Promise<void> {98 const deadline = Date.now() + milliseconds;99 while (!check()) {100 if (Date.now() > deadline) throw new Error(`${label} did not settle`);101 await new Promise((resolve) => setTimeout(resolve, 50));102 }103}104105Deno.test("a wrapped key turns Bob's pending read into plaintext", async () => {106 clearFingerprintPins("read-sync-app");107 clearSharedFieldKeys();108 const server = await startLocalJazzServer({ allowLocalFirstAuth: true });109 await deploy({110 appId: server.appId,111 serverUrl: server.url,112 adminSecret: server.adminSecret,113 schema: app,114 permissions,115 });116 const alice = await createDb({117 appId: server.appId,118 serverUrl: server.url,119 secret: secret(1),120 userBranch: "main",121 driver: { type: "memory" },122 });123 const bob = await createDb({124 appId: server.appId,125 serverUrl: server.url,126 secret: secret(2),127 userBranch: "main",128 driver: { type: "memory" },129 });130 let stopWatcher: (() => void) | null = null;131 try {132 const aliceId = alice.getAuthState().session?.user_id as string;133 const bobId = bob.getAuthState().session?.user_id as string;134 const aliceIdentity = await deriveSharedFieldIdentity("alice-shared-secret");135 const bobIdentity = await deriveSharedFieldIdentity("bob-shared-secret");136137 // Both accounts publish their keys.138 await within(139 ensureDirectoryEntry({140 db: alice as never,141 directory: app.keyDirectory,142 userId: aliceId,143 identity: aliceIdentity,144 }).then(() => undefined),145 "alice publishes",146 );147 await within(148 ensureDirectoryEntry({149 db: bob as never,150 directory: app.keyDirectory,151 userId: bobId,152 identity: bobIdentity,153 }).then(() => undefined),154 "bob publishes",155 );156157 // Alice creates the workspace with both members and seals a doc under a158 // fresh field key.159 const workspace = await within(160 alice.insert(app.workspaces, { name: "shared" }).wait({ tier: "global" }),161 "workspace insert",162 );163 for (const memberId of [aliceId, bobId]) {164 await within(165 alice.insert(app.workspaceMembers, {166 groupId: workspace.id,167 user_id: memberId,168 ...groupRoleCapabilities(memberId === aliceId ? "admin" : "writer"),169 }).wait({ tier: "global" }),170 `membership for ${memberId}`,171 );172 }173 const fieldKey = generateFieldKey();174 const sealed = sealSharedValue({175 plaintext: "sealed for the whole workspace",176 fieldKey,177 label: "readsync.docs.body",178 scope: { groupTable: "workspaces", groupId: workspace.id, generation: 1 },179 });180 const doc = await within(181 alice.insert(app.docs, {182 workspaceId: workspace.id,183 title: "plain title",184 body: sealed as never,185 }).wait({ tier: "global" }),186 "sealed doc insert",187 );188189 // Bob sees the row but the body is pending — no key has arrived. The row190 // itself propagates asynchronously, so poll for its arrival first.191 let pendingBody: SharedFieldValue<string> | undefined;192 await until(() => {193 void bob.all(app.docs.where({ id: doc.id })).then((rows) => {194 pendingBody = rows[0]?.body as unknown as SharedFieldValue<string>;195 });196 return pendingBody !== undefined;197 }, "bob's doc row arrives");198 assert(199 pendingBody?.state === "pending-key",200 `bob's keyless read must be pending (saw ${JSON.stringify(pendingBody)})`,201 );202203 // Bob's watcher comes up, then Alice wraps the key to him.204 installSharedFieldIdentity(bobIdentity);205 stopWatcher = startSharedFieldKeyWatcher({206 db: bob as never,207 appId: "read-sync-app",208 userId: bobId,209 configs: [{ label: "readsync.docs.body", kind: "text", ...SHARED_OPTIONS }],210 findTable: (name) =>211 name === "workspaceFieldKeys"212 ? app.workspaceFieldKeys213 : name === "keyDirectory"214 ? app.keyDirectory215 : null,216 onAlert: (alert) => {217 throw new Error(`unexpected watcher alert: ${alert.code} — ${alert.detail}`);218 },219 });220 await within(221 alice.insert(app.workspaceFieldKeys, {222 groupId: workspace.id,223 recipient_user_id: bobId,224 sender_user_id: aliceId,225 generation: 1,226 wrapped_key: wrapFieldKey({227 fieldKey,228 senderSecret: aliceIdentity.secret,229 recipientPublic: bobIdentity.publicKey,230 context: {231 groupTable: "workspaces",232 groupId: workspace.id,233 generation: 1,234 recipientUserId: bobId,235 senderUserId: aliceId,236 },237 }),238 recipient_fingerprint: bobIdentity.fingerprint,239 sender_fingerprint: aliceIdentity.fingerprint,240 }).wait({ tier: "global" }),241 "alice wraps for bob",242 );243244 // The wrap syncs, the watcher installs, and the same query decrypts.245 let readyBody: SharedFieldValue<string> | undefined;246 await until(() => {247 void bob.all(app.docs.where({ id: doc.id })).then((rows) => {248 readyBody = rows[0]?.body as unknown as SharedFieldValue<string>;249 });250 return readyBody !== undefined && readyBody.state === "ready";251 }, "bob's read settles to ready");252 assert(253 readyBody !== undefined && sharedFieldReady(readyBody) &&254 readyBody.value === "sealed for the whole workspace",255 "bob must decrypt the shared body after the wrap arrives",256 );257 } finally {258 stopWatcher?.();259 clearSharedFieldIdentity();260 clearSharedFieldKeys();261 clearFingerprintPins("read-sync-app");262 clearSharedColumnRegistry();263 clearEncryptedColumnRegistry();264 await Promise.allSettled([265 within(alice.logout(), "Alice cleanup", 3_000),266 within(bob.logout(), "Bob cleanup", 3_000),267 ]);268 await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);269 }270});