clone

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

feat(schema/runtime): shared encrypted columns — wrapped keys, keyring, and the read side (#162)

FelineStateMachine committed 0c57905
Stage 3 of shared-field encryption (#154). s.sharedEncryptedText/Json
declare fields sealed under group field keys; reads are state-valued
(ready / pending-key / corrupt) because key-pending is the normal state
of a freshly added member, and a thrown transform would fail the whole
query. The runtime's wrapped-key watcher verifies each sender through
the pin store, unwraps, and installs keys into the keyring; live queries
resubscribe on keyring change, so pending rows re-materialize into
plaintext with no redefinition — proven end to end against the real
server. Refused wraps and substituted directory keys surface as
diagnostics alerts and install nothing: refusing the key IS the
detection. Writes fail closed at the transform until the mutation layer
seals them (Stage 4).

groupAccess gains the fieldKeys option compiling recipient-or-member
reads and sender-is-self member-scoped writes.

Also pins a significant engine finding uncovered by the read-side test:
on alpha.53, rows whose read policy is an exists-based function
condition never propagate cross-account over sync, while always() and
direct object conditions do (#161). The shared-field design routes
around it — wrapped keys ride a direct recipient condition, the
directory reads always(), and content protection comes from sealing —
and an engine canary fails loudly when a bump fixes propagation.
commit
0c57905cf51886a9b410489e455412896735a4e0
parent
0beece2705d2958e52046734f6e89a11f157c8f7
author
FelineStateMachine <dami.etoile@gmail.com>
18 files changed, +1299 -6
Mdeno.json2+-
Mpackage/access/mod.ts1+
Mpackage/access/policies.ts38++++
Mpackage/access/schema.ts30+++-
Mpackage/access/shared_directory_sync_test.ts117+++++++++++++-
Apackage/access/shared_fields_read_sync_test.ts270++++++++++++++++++++++++++++++
Mpackage/astro/mod.ts2+
Mpackage/runtime/diagnostics.ts27+++
Mpackage/runtime/live-query-store.ts13+
Mpackage/runtime/runtime.ts66+++++++-
Mpackage/runtime/shared-field-keys.ts159++++++++++++++++++
Mpackage/runtime/shared_field_keys_test.ts142++++++++++++++++-
Mpackage/schema/encrypted.ts12+-
Mpackage/schema/mod.ts28+++
Apackage/schema/shared-encrypted.ts142++++++++++++++++
Mpackage/schema/shared-keyring.ts68++++++++
Apackage/schema/shared-registry.ts45+++++
Apackage/schema/shared_encrypted_test.ts143++++++++++++++++
M deno.json +1 -1
@@ -64,7 +64,7 @@
6464 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",6565 "doc": "deno run --allow-run tools/doc_lint.ts",6666 "docs:links": "deno run --allow-read tools/docs_links.ts",67- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/encrypted_types_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/schema/private_table_test.ts package/schema/shared_crypto_test.ts package/testing package/tooling apps/reference/tests",67+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/encrypted_types_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/schema/private_table_test.ts package/schema/shared_crypto_test.ts package/schema/shared_encrypted_test.ts package/testing package/tooling apps/reference/tests",6868 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",6969 "test:effects": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/effects_sync_test.ts",7070 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",
M package/access/mod.ts +1 -0
M package/access/policies.ts +38 -0
@@ -24,6 +24,8 @@ export type GroupAccessTemplate = {
2424 readonly members: AccessTable;2525 readonly resources: readonly AccessTable[];2626 readonly groupId: string;27+ /** The group's wrapped-field-key table, when it hosts shared columns. */28+ readonly fieldKeys?: AccessTable;2729 };2830 /** Shared-field key-directory policy template. */2931 export type SharedFieldAccessTemplate = {
@@ -110,6 +112,10 @@ export function groupAccess(config: {
110112 members: AccessTable;111113 resources: AccessTable | readonly AccessTable[];112114 groupId: string;115+ /** Wrapped-field-key table for groups hosting shared encrypted columns116+ * (declared with `sharedFieldKeyTable`). Readable by each row's recipient117+ * or any member; writable by a member signing as themselves. */118+ fieldKeys?: AccessTable;113119 }): GroupAccessTemplate {114120 return {115121 kind: "group",
@@ -117,6 +123,7 @@ export function groupAccess(config: {
117123 members: config.members,118124 resources: Array.isArray(config.resources) ? config.resources : [config.resources],119125 groupId: config.groupId,126+ ...(config.fieldKeys ? { fieldKeys: config.fieldKeys } : {}),120127 };121128 }122129
@@ -193,6 +200,15 @@ function validateTemplate(template: AccessTemplate): void {
193200 for (const resource of template.resources) {194201 requireColumn(resource, template.groupId, "Uuid", template.groups._table);195202 }203+ if (template.fieldKeys) {204+ requireColumn(template.fieldKeys, "groupId", "Uuid", template.groups._table);205+ requireColumn(template.fieldKeys, "recipient_user_id", "Text");206+ requireColumn(template.fieldKeys, "sender_user_id", "Text");207+ requireColumn(template.fieldKeys, "generation", "Integer");208+ requireColumn(template.fieldKeys, "wrapped_key", "Text");209+ requireColumn(template.fieldKeys, "recipient_fingerprint", "Text");210+ requireColumn(template.fieldKeys, "sender_fingerprint", "Text");211+ }196212 }197213 }198214
@@ -447,6 +463,28 @@ export function defineAccessPolicies<TApp extends object>(
447463 ])448464 );449465 }466+ if (template.fieldKeys) {467+ // Wrapped field keys: recipients and members read (members see who468+ // holds keys, which key repair needs); any member may wrap but only469+ // as themselves — a malicious member is already inside the crypto470+ // boundary, so restricting senders to admins buys nothing and hurts471+ // availability. Recipients may shed their own wraps; admins clean up.472+ const fieldKeys = policy[template.fieldKeys._table];473+ fieldKeys.allowRead.where((row: Record<string, unknown>) =>474+ anyOf([{ recipient_user_id: session.user_id }, isMember(row.groupId)])475+ );476+ fieldKeys.allowInsert.where((row: Record<string, unknown>) =>477+ allOf([{ sender_user_id: session.user_id }, isMember(row.groupId)])478+ );479+ fieldKeys.allowUpdate.where({ sender_user_id: session.user_id });480+ fieldKeys.allowDelete.where((row: Record<string, unknown>) =>481+ anyOf([482+ { sender_user_id: session.user_id },483+ { recipient_user_id: session.user_id },484+ isAdmin(row.groupId),485+ ])486+ );487+ }450488 }451489 raw?.({ policy, session, anyOf, allOf, allowedTo });452490 });
M package/access/schema.ts +29 -1
@@ -1,5 +1,5 @@
11 import { schema as s } from "jazz-tools";2-import type { BooleanColumn, DefinedTable, RefColumn, StringColumn } from "jazz-tools";2+import type { BooleanColumn, DefinedTable, IntColumn, RefColumn, StringColumn } from "jazz-tools";33 44 function requireTableName(name: string, helper: string): void {55 if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) {
@@ -87,3 +87,31 @@ export function sharedFieldDirectoryTable(): DefinedTable<{
8787 fingerprint: s.string(),8888 });8989 }90+91+/** Creates the wrapped-field-key table for one group table: one row per92+ * (recipient, generation) holding the group's field key sealed to that93+ * member's public key. Rows are ordinary synced data the server relays but94+ * cannot open; the sender's static key inside the wrap is what makes a95+ * server-minted row a detected forgery rather than a readable key. */96+export function sharedFieldKeyTable<const Group extends string>(97+ group: Group,98+): DefinedTable<{99+ groupId: RefColumn<Group>;100+ recipient_user_id: StringColumn;101+ sender_user_id: StringColumn;102+ generation: IntColumn;103+ wrapped_key: StringColumn;104+ recipient_fingerprint: StringColumn;105+ sender_fingerprint: StringColumn;106+}> {107+ requireTableName(group, "sharedFieldKeyTable");108+ return s.table({109+ groupId: s.ref(group),110+ recipient_user_id: s.string(),111+ sender_user_id: s.string(),112+ generation: s.int(),113+ wrapped_key: s.string(),114+ recipient_fingerprint: s.string(),115+ sender_fingerprint: s.string(),116+ });117+}
M package/access/shared_directory_sync_test.ts +116 -1
@@ -2,7 +2,7 @@
22 // compiled directory policy is world-readable with self-only writes, and33 // encrypted columns are refused in the directory's template positions.44 5-import { createDb, schema as s } from "jazz-tools";5+import { createDb, schema as jazzSchema, schema as s } from "jazz-tools";66 import { deploy, startLocalJazzServer } from "jazz-tools/testing";77 import { defineAccessPolicies, sharedFieldAccess, sharedFieldDirectoryTable } from "./mod.ts";88 import { s as lofiSchema } from "../schema/mod.ts";
@@ -133,3 +133,118 @@ Deno.test("a directory with a sealed column fails configuration", () => {
133133 clearEncryptedColumnRegistry();134134 }135135 });136+137+// ENGINE CANARY (alpha.53): rows whose read policy is an exists-based138+// function condition never propagate to other accounts over real sync, while139+// always() and direct object conditions propagate immediately. The140+// collaboration templates' cross-account reads are bounded by this; the141+// shared-field design routes around it (wrapped keys use a direct recipient142+// condition, the directory reads always()). This test pins the gap: when an143+// engine bump fixes propagation, it fails loudly — revisit the read-side144+// guidance and the templates then.145+Deno.test("engine canary: exists-gated rows do not propagate cross-account", async () => {146+ const canaryApp = jazzSchema.defineApp({147+ rooms: jazzSchema.table({ name: jazzSchema.string() }),148+ roomMembers: jazzSchema.table({149+ groupId: jazzSchema.ref("rooms"),150+ user_id: jazzSchema.string(),151+ }),152+ gated: jazzSchema.table({ roomId: jazzSchema.ref("rooms"), title: jazzSchema.string() }),153+ open: jazzSchema.table({ roomId: jazzSchema.ref("rooms"), title: jazzSchema.string() }),154+ });155+ const canaryPermissions = jazzSchema.definePermissions(canaryApp, (jazzContext) => {156+ const { policy, session } = jazzContext as unknown as {157+ policy: Record<158+ string,159+ Record<string, { where(input: unknown): unknown; always(): unknown }> & {160+ exists: { where(input: unknown): unknown };161+ }162+ >;163+ session: { user_id: unknown };164+ };165+ for (const table of ["rooms", "roomMembers", "open"]) {166+ policy[table].allowRead.always();167+ policy[table].allowInsert.always();168+ policy[table].allowUpdate.always();169+ policy[table].allowDelete.always();170+ }171+ policy.gated.allowRead.where((row: Record<string, unknown>) =>172+ policy.roomMembers.exists.where({ groupId: row.roomId, user_id: session.user_id })173+ );174+ policy.gated.allowInsert.always();175+ policy.gated.allowUpdate.always();176+ policy.gated.allowDelete.always();177+ });178+179+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });180+ await deploy({181+ appId: server.appId,182+ serverUrl: server.url,183+ adminSecret: server.adminSecret,184+ schema: canaryApp,185+ permissions: canaryPermissions,186+ });187+ const alice = await createDb({188+ appId: server.appId,189+ serverUrl: server.url,190+ secret: secret(11),191+ userBranch: "main",192+ driver: { type: "memory" },193+ });194+ const bob = await createDb({195+ appId: server.appId,196+ serverUrl: server.url,197+ secret: secret(12),198+ userBranch: "main",199+ driver: { type: "memory" },200+ });201+ try {202+ const aliceId = alice.getAuthState().session?.user_id as string;203+ const bobId = bob.getAuthState().session?.user_id as string;204+ const room = await within(205+ alice.insert(canaryApp.rooms, { name: "canary" }).wait({ tier: "global" }),206+ "room insert",207+ );208+ for (const memberId of [aliceId, bobId]) {209+ await within(210+ alice.insert(canaryApp.roomMembers, { groupId: room.id, user_id: memberId })211+ .wait({ tier: "global" }),212+ "membership insert",213+ );214+ }215+ const gated = await within(216+ alice.insert(canaryApp.gated, { roomId: room.id, title: "gated" })217+ .wait({ tier: "global" }),218+ "gated insert",219+ );220+ const open = await within(221+ alice.insert(canaryApp.open, { roomId: room.id, title: "open" }).wait({ tier: "global" }),222+ "open insert",223+ );224+225+ // The always()-gated control row arrives promptly...226+ const deadline = Date.now() + 5_000;227+ let openSeen = 0;228+ while (openSeen === 0 && Date.now() < deadline) {229+ openSeen = (await bob.all(canaryApp.open.where({ id: open.id }))).length;230+ if (openSeen === 0) await new Promise((resolve) => setTimeout(resolve, 100));231+ }232+ assert(openSeen === 1, "the always()-gated control row must propagate to bob");233+234+ // ...while the exists-gated row stays absent well past the control's235+ // arrival. This is the pinned gap, not the desired behavior.236+ await new Promise((resolve) => setTimeout(resolve, 1_500));237+ const gatedSeen = await bob.all(canaryApp.gated.where({ id: gated.id }));238+ assert(239+ gatedSeen.length === 0,240+ "pinned engine behavior changed: exists-gated rows now propagate cross-account — " +241+ "revisit the read-side guidance, the collaboration templates, and this canary",242+ );243+ } finally {244+ await Promise.allSettled([245+ within(alice.logout(), "Alice cleanup", 3_000),246+ within(bob.logout(), "Bob cleanup", 3_000),247+ ]);248+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);249+ }250+});
A package/access/shared_fields_read_sync_test.ts +270 -0
@@ -0,0 +1,270 @@
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.6+7+import { createDb, schema as jazz } from "jazz-tools";8+import { deploy, startLocalJazzServer } from "jazz-tools/testing";9+import {10+ defineAccessPolicies,11+ groupAccess,12+ groupMembershipTable,13+ groupRoleCapabilities,14+ sharedFieldAccess,15+ sharedFieldDirectoryTable,16+ sharedFieldKeyTable,17+} from "./mod.ts";18+import { s } from "../schema/mod.ts";19+import {20+ clearSharedColumnRegistry,21+ clearSharedFieldKeys,22+ sharedFieldReady,23+ type SharedFieldValue,24+} from "../schema/mod.ts";25+import { generateFieldKey, sealSharedValue, wrapFieldKey } from "../schema/shared-crypto.ts";26+import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";27+import { clearSharedFieldIdentity, installSharedFieldIdentity } from "../schema/shared-keyring.ts";28+import {29+ clearFingerprintPins,30+ deriveSharedFieldIdentity,31+ ensureDirectoryEntry,32+ startSharedFieldKeyWatcher,33+} from "../runtime/shared-field-keys.ts";34+import { assert } from "../runtime/test-assert.ts";35+36+const SHARED_OPTIONS = {37+ group: "workspaces",38+ groupIdColumn: "workspaceId",39+ keys: "workspaceFieldKeys",40+ directory: "keyDirectory",41+} as const;42+43+const 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.61+const 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+});76+77+function 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+}82+83+async 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+}96+97+async 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+}104+105+Deno.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");136+137+ // 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+ );156+157+ // 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+ );188+189+ // 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+ );202+203+ // 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", ...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+ );243+244+ // 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+});
M package/astro/mod.ts +2 -0
M package/runtime/diagnostics.ts +27 -0
M package/runtime/live-query-store.ts +13 -0
@@ -1,6 +1,7 @@
11 import type { Db, QueryBuilder } from "jazz-tools";22 import type { RuntimeDiagnostics } from "./diagnostics.ts";33 import { getRuntime, runtimeRecreatedEvent, updateRuntimeDiagnostics } from "./runtime.ts";4+import { subscribeSharedKeyring } from "../schema/shared-keyring.ts";45 import type { TableRow } from "./table-store.ts";56 67 /** Honest read state for an arbitrary typed Jazz query. */
@@ -17,6 +18,12 @@ type QuerySubscriptionDb = Pick<Db, "subscribeAll">;
1718 export type LiveQueryEnvironment = {1819 getDb(): Promise<QuerySubscriptionDb>;1920 subscribeRuntimeRecreation(listener: () => void): () => void;21+ /** Fires when a shared field key is installed; stores resubscribe so rows22+ * previously surfaced as key-pending re-materialize into plaintext. Key23+ * installs are rare (boot, membership changes), so restarting every active24+ * query is simpler than tracking which queries touch shared columns and25+ * costs nothing in steady state. */26+ subscribeSharedKeyring?(listener: () => void): () => void;2027 updateDiagnostics(update: (diagnostics: RuntimeDiagnostics) => void): void;2128 };2229
@@ -44,6 +51,7 @@ export class LiveQueryStore<T extends TableRow> {
4451 #snapshot: LiveQuerySnapshot<T> = { status: "loading", rows: [], error: null };4552 #vendorUnsubscribe: (() => void) | null = null;4653 #stopRuntimeRecreation: (() => void) | null = null;54+ #stopKeyring: (() => void) | null = null;4755 #generation = 0;4856 #disposed = false;4957
@@ -104,6 +112,8 @@ export class LiveQueryStore<T extends TableRow> {
104112 this.#stopRuntimeRecreation = this.#environment.subscribeRuntimeRecreation(() =>105113 this.#restart()106114 );115+ this.#stopKeyring = this.#environment.subscribeSharedKeyring?.(() => this.#restart()) ??116+ null;107117 this.#connect();108118 } catch (error) {109119 this.#fail(error);
@@ -149,6 +159,8 @@ export class LiveQueryStore<T extends TableRow> {
149159 this.#closeVendorSubscription();150160 this.#stopRuntimeRecreation?.();151161 this.#stopRuntimeRecreation = null;162+ this.#stopKeyring?.();163+ this.#stopKeyring = null;152164 }153165 154166 #closeVendorSubscription(): void {
@@ -256,6 +268,7 @@ const defaultRegistry = new LiveQueryRegistry({
256268 globalThis.addEventListener(runtimeRecreatedEvent, listener);257269 return () => globalThis.removeEventListener(runtimeRecreatedEvent, listener);258270 },271+ subscribeSharedKeyring,259272 updateDiagnostics,260273 });261274
M package/runtime/runtime.ts +65 -1
@@ -9,7 +9,16 @@ import { assertSchemaWritable, schemaCompatGate, subscribeSchemaCompat } from ".
99 import { resolveStoreStatus } from "./store-status.ts";1010 import { acquireUpgradeWriteLock } from "./upgrade-coordination.ts";1111 import { setEncryptedColumnKey } from "../schema/encrypted.ts";12-import { installSharedFieldIdentityFromSecret } from "./shared-field-keys.ts";12+import {13+ ensureDirectoryEntry,14+ installSharedFieldIdentityFromSecret,15+ type SharedFieldWatchAlert,16+ startSharedFieldKeyWatcher,17+} from "./shared-field-keys.ts";18+import { clearSharedFieldKeys, sharedFieldIdentityOrNull } from "../schema/shared-keyring.ts";19+import { sharedColumnConfigs } from "../schema/shared-registry.ts";20+import { recordSharedFieldAlert } from "./diagnostics.ts";21+import { activeAppId } from "./config.ts";1322 import { assertDurableBrowser } from "./device-capabilities.ts";1423 import {1524 createTableStore,
@@ -55,6 +64,7 @@ type RuntimeSlot = {
5564 diagnosticListeners: Set<() => void>;5665 principal: string | null;5766 serverConfigured: boolean;67+ stopSharedFieldWatcher?: () => void;5868 };5969 6070 const slotName = "__LOFI_ALPHA53_RUNTIME__";
@@ -256,6 +266,10 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
256266 bootProgressTracker.mark("opening");257267 const secret = await resolveAccountSecret();258268 await installEncryptedColumnKey(secret);269+ // Field keys from a previous principal must never survive into a new270+ // client; the watcher re-installs the current account's keys as wraps271+ // arrive.272+ clearSharedFieldKeys();259273 await installSharedFieldIdentityFromSecret(secret);260274 const namespace = await accountNamespace(secret);261275 const db = await createDb(
@@ -280,6 +294,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
280294 }281295 state.principal = db.getAuthState().session?.user_id ?? null;282296 state.serverConfigured = connect;297+ startSharedFieldRuntime(state, db, connect);283298 state.diagnostics.storageState = "persistent-driver-open";284299 state.diagnostics.startupFailure = null;285300 state.diagnostics.clientsCreated += 1;
@@ -295,7 +310,56 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
295310 296311 // Shutdown deliberately avoids `logout()`, which would clear local account297312 // state that must carry forward into a restored or sync-enabled runtime.313+// The shared-field runtime: one wrapped-key watcher per client, plus the314+// one-time directory self-publication. Both exist only when the schema315+// declares shared columns and a sync location is active — local-only apps316+// never pay for them.317+function startSharedFieldRuntime(state: RuntimeSlot, db: Db, connect: boolean): void {318+ const configs = sharedColumnConfigs();319+ const userId = db.getAuthState().session?.user_id;320+ const identity = sharedFieldIdentityOrNull();321+ if (configs.length === 0 || !connect || !userId || !identity) return;322+ const alert = (input: SharedFieldWatchAlert) => {323+ recordSharedFieldAlert(state.diagnostics, { ...input, at: new Date().toISOString() });324+ notifyDiagnostics(state);325+ };326+ state.stopSharedFieldWatcher = startSharedFieldKeyWatcher({327+ db: db as never,328+ appId: activeAppId(),329+ userId,330+ configs,331+ findTable: (name) => findRuntimeTable(name),332+ onAlert: alert,333+ });334+ const directoryNames = new Set(configs.map((config) => config.directory));335+ for (const directoryName of directoryNames) {336+ const directory = findRuntimeTable(directoryName);337+ if (!directory) continue;338+ void ensureDirectoryEntry({ db: db as never, directory, userId, identity })339+ .then((outcome) => {340+ if (outcome.state === "self-key-conflict") {341+ recordSharedFieldAlert(state.diagnostics, {342+ code: "self-key-conflict",343+ userId,344+ detail: "the published directory key for this account does not match its derived key",345+ at: new Date().toISOString(),346+ });347+ notifyDiagnostics(state);348+ }349+ }).catch(() => {350+ // Publication retries on the next boot; reads stay pending meanwhile.351+ });352+ }353+}354+355+function stopSharedFieldRuntime(state: RuntimeSlot): void {356+ state.stopSharedFieldWatcher?.();357+ state.stopSharedFieldWatcher = undefined;358+ clearSharedFieldKeys();359+}360+298361 async function destroyClient(state: RuntimeSlot, db: Db): Promise<void> {362+ stopSharedFieldRuntime(state);299363 await db.shutdown();300364 state.diagnostics.activeClients -= 1;301365 notifyDiagnostics(state);
M package/runtime/shared-field-keys.ts +159 -0
@@ -11,12 +11,17 @@ import {
1111 publicKeyFingerprint,1212 SharedFieldError,1313 sharedFieldPublicKey,14+ unwrapFieldKey,1415 } from "../schema/shared-crypto.ts";1516 import {1617 clearSharedFieldIdentity,1718 installSharedFieldIdentity,19+ installSharedFieldKey,20+ requireSharedFieldIdentity,1821 type SharedFieldIdentity,22+ sharedKeyScope,1923 } from "../schema/shared-keyring.ts";24+import type { SharedColumnConfig } from "../schema/shared-registry.ts";2025 2126 export { clearSharedFieldIdentity };2227
@@ -217,3 +222,157 @@ export function directoryPublicKey(row: { public_key: string; algo: string }): U
217222 }218223 return bytes;219224 }225+226+// --- Wrapped-key watcher -----------------------------------------------------227+228+type WatcherDb = {229+ subscribeAll(230+ query: unknown,231+ onDelta: (delta: { all: unknown[] }) => void,232+ onError?: (error: unknown) => void,233+ ): () => void;234+ all(query: unknown): Promise<unknown[]>;235+};236+237+type WrappedKeyRow = {238+ groupId: string;239+ recipient_user_id: string;240+ sender_user_id: string;241+ generation: number;242+ wrapped_key: string;243+ sender_fingerprint: string;244+};245+246+/** One anomaly the watcher surfaces instead of installing a key. */247+export type SharedFieldWatchAlert = {248+ code: "peer-key-changed" | "wrap-invalid";249+ userId: string;250+ detail: string;251+};252+253+/**254+ * Watches the wrapped-key tables named by the shared-column registry for255+ * rows addressed to this account, resolves each sender's public key through256+ * the pin store, unwraps, and installs field keys into the keyring. A sender257+ * whose published key disagrees with its pin, or a wrap that fails258+ * authentication, surfaces as an alert and installs nothing — refusing the259+ * key IS the detection.260+ *261+ * Returns a teardown that closes every subscription.262+ */263+export function startSharedFieldKeyWatcher(input: {264+ db: WatcherDb;265+ appId: string;266+ userId: string;267+ configs: readonly SharedColumnConfig[];268+ findTable(name: string): { where(condition: Record<string, unknown>): unknown } | null;269+ onAlert(alert: SharedFieldWatchAlert): void;270+}): () => void {271+ const stops: Array<() => void> = [];272+ const watched = new Set<string>();273+ for (const config of input.configs) {274+ const watchKey = `${config.keys}|${config.directory}|${config.group}`;275+ if (watched.has(watchKey)) continue;276+ watched.add(watchKey);277+ const keysTable = input.findTable(config.keys);278+ const directoryTable = input.findTable(config.directory);279+ if (!keysTable || !directoryTable) {280+ input.onAlert({281+ code: "wrap-invalid",282+ userId: input.userId,283+ detail: `shared column "${config.label}" names undeclared table ` +284+ `"${!keysTable ? config.keys : config.directory}"`,285+ });286+ continue;287+ }288+ const processed = new Set<string>();289+ const stop = input.db.subscribeAll(290+ keysTable.where({ recipient_user_id: input.userId }),291+ (delta) => {292+ for (const raw of delta.all as WrappedKeyRow[]) {293+ const rowKey = `${config.group}/${raw.groupId}#${raw.generation}@${raw.sender_user_id}` +294+ `:${raw.wrapped_key}`;295+ if (processed.has(rowKey)) continue;296+ processed.add(rowKey);297+ void installWrappedKey(input, config, directoryTable, raw);298+ }299+ },300+ () => {301+ // Subscription errors surface through the live-query stores consumers302+ // actually watch; the watcher retries on the next runtime recreation.303+ },304+ );305+ stops.push(stop);306+ }307+ return () => {308+ for (const stop of stops) stop();309+ };310+}311+312+async function installWrappedKey(313+ input: {314+ db: WatcherDb;315+ appId: string;316+ userId: string;317+ onAlert(alert: SharedFieldWatchAlert): void;318+ },319+ config: SharedColumnConfig,320+ directoryTable: { where(condition: Record<string, unknown>): unknown },321+ row: WrappedKeyRow,322+): Promise<void> {323+ try {324+ const identity = requireSharedFieldIdentity();325+ const entries = await input.db.all(326+ directoryTable.where({ user_id: row.sender_user_id }),327+ ) as Array<{ user_id: string; algo: string; public_key: string }>;328+ const entry = entries[0];329+ if (entry === undefined) {330+ input.onAlert({331+ code: "wrap-invalid",332+ userId: row.sender_user_id,333+ detail: `a wrap from ${row.sender_user_id} arrived before its directory entry`,334+ });335+ return;336+ }337+ const senderPublic = directoryPublicKey(entry);338+ const observedFingerprint = publicKeyFingerprint(senderPublic);339+ if (!verifyAndPinFingerprint(input.appId, row.sender_user_id, observedFingerprint)) {340+ input.onAlert({341+ code: "peer-key-changed",342+ userId: row.sender_user_id,343+ detail: `the published key for ${row.sender_user_id} no longer matches its pin; ` +344+ "refusing its wraps until the key is re-trusted",345+ });346+ return;347+ }348+ if (row.sender_fingerprint !== observedFingerprint) {349+ input.onAlert({350+ code: "wrap-invalid",351+ userId: row.sender_user_id,352+ detail: "a wrap's declared sender fingerprint disagrees with the directory",353+ });354+ return;355+ }356+ const fieldKey = unwrapFieldKey({357+ wrapped: row.wrapped_key,358+ recipientSecret: identity.secret,359+ senderPublic,360+ context: {361+ groupTable: config.group,362+ groupId: row.groupId,363+ generation: row.generation,364+ recipientUserId: input.userId,365+ senderUserId: row.sender_user_id,366+ },367+ });368+ installSharedFieldKey(sharedKeyScope(config.group, row.groupId), row.generation, fieldKey);369+ } catch (error) {370+ input.onAlert({371+ code: "wrap-invalid",372+ userId: row.sender_user_id,373+ detail: error instanceof SharedFieldError374+ ? error.message375+ : `a wrap failed to install: ${String(error)}`,376+ });377+ }378+}
M package/runtime/shared_field_keys_test.ts +141 -1
@@ -10,7 +10,15 @@ import {
1010 trustPeerKey,1111 verifyAndPinFingerprint,1212 } from "./shared-field-keys.ts";13-import { SharedFieldError } from "../schema/shared-crypto.ts";13+import { generateFieldKey, SharedFieldError, wrapFieldKey } from "../schema/shared-crypto.ts";14+import {15+ clearSharedFieldIdentity,16+ clearSharedFieldKeys,17+ getSharedFieldKey,18+ installSharedFieldIdentity,19+ sharedKeyScope,20+} from "../schema/shared-keyring.ts";21+import { startSharedFieldKeyWatcher } from "./shared-field-keys.ts";1422 import { assert } from "./test-assert.ts";1523 1624 const APP = "pin-test-app";
@@ -131,3 +139,135 @@ Deno.test("directory publication inserts once and reports conflicts", async () =
131139 );132140 assert(rows.length === 1 && rows[0].public_key === "tampered-key", "no overwrite occurred");133141 });142+143+Deno.test("the watcher unwraps valid wraps and refuses forgeries and substitutions", async () => {144+ clearFingerprintPins("watch-app");145+ clearSharedFieldKeys();146+ try {147+ const alice = await deriveSharedFieldIdentity("alice-secret");148+ const bob = await deriveSharedFieldIdentity("bob-secret");149+ const mallory = await deriveSharedFieldIdentity("mallory-secret");150+ installSharedFieldIdentity(bob);151+152+ const fieldKey = generateFieldKey();153+ const context = {154+ groupTable: "workspaces",155+ groupId: "ws-1",156+ generation: 1,157+ recipientUserId: "bob",158+ senderUserId: "alice",159+ };160+ const validWrap = wrapFieldKey({161+ fieldKey,162+ senderSecret: alice.secret,163+ recipientPublic: bob.publicKey,164+ context,165+ });166+ const forgedWrap = wrapFieldKey({167+ fieldKey: generateFieldKey(),168+ senderSecret: mallory.secret,169+ recipientPublic: bob.publicKey,170+ context: { ...context, generation: 2 },171+ });172+173+ const encode = (bytes: Uint8Array) => {174+ let binary = "";175+ for (const byte of bytes) binary += String.fromCharCode(byte);176+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");177+ };178+ const directoryRows = [{179+ user_id: "alice",180+ algo: "x25519-v1",181+ public_key: encode(alice.publicKey),182+ }];183+ const wrapRows = [184+ {185+ groupId: "ws-1",186+ recipient_user_id: "bob",187+ sender_user_id: "alice",188+ generation: 1,189+ wrapped_key: validWrap,190+ sender_fingerprint: alice.fingerprint,191+ },192+ // Forged: Mallory minted it but the row claims Alice sent it.193+ {194+ groupId: "ws-1",195+ recipient_user_id: "bob",196+ sender_user_id: "alice",197+ generation: 2,198+ wrapped_key: forgedWrap,199+ sender_fingerprint: alice.fingerprint,200+ },201+ ];202+203+ const delivery: { fn: ((delta: { all: unknown[] }) => void) | null } = { fn: null };204+ const db = {205+ subscribeAll: (_query: unknown, onDelta: (delta: { all: unknown[] }) => void) => {206+ delivery.fn = onDelta;207+ return () => {};208+ },209+ all: (_query: unknown) => Promise.resolve([...directoryRows]),210+ };211+ const alerts: Array<{ code: string; userId: string }> = [];212+ const stop = startSharedFieldKeyWatcher({213+ db,214+ appId: "watch-app",215+ userId: "bob",216+ configs: [{217+ label: "docs.body",218+ group: "workspaces",219+ groupIdColumn: "workspaceId",220+ keys: "workspaceFieldKeys",221+ directory: "keyDirectory",222+ }],223+ findTable: (name) => ({ where: (condition) => ({ name, condition }) }),224+ onAlert: (alert) => alerts.push(alert),225+ });226+ assert(delivery.fn !== null, "the watcher must subscribe to the key table");227+ delivery.fn!({ all: wrapRows });228+ // Unwrap resolution is async (directory fetch); yield until settled.229+ await new Promise((resolve) => setTimeout(resolve, 20));230+231+ const scope = sharedKeyScope("workspaces", "ws-1");232+ const installed = getSharedFieldKey(scope, 1);233+ assert(234+ installed !== null && installed.every((byte, index) => byte === fieldKey[index]),235+ "the valid wrap must install its field key",236+ );237+ assert(getSharedFieldKey(scope, 2) === null, "the forged wrap must install nothing");238+ assert(239+ alerts.some((alert) => alert.code === "wrap-invalid" && alert.userId === "alice"),240+ "the forged wrap must surface an alert",241+ );242+243+ // A substituted directory key refuses before any unwrap: Alice's pin is244+ // set from the valid round; swap the directory to Mallory's key.245+ directoryRows[0] = {246+ user_id: "alice",247+ algo: "x25519-v1",248+ public_key: encode(mallory.publicKey),249+ };250+ alerts.length = 0;251+ delivery.fn!({252+ all: [{253+ groupId: "ws-1",254+ recipient_user_id: "bob",255+ sender_user_id: "alice",256+ generation: 3,257+ wrapped_key: validWrap,258+ sender_fingerprint: mallory.fingerprint,259+ }],260+ });261+ await new Promise((resolve) => setTimeout(resolve, 20));262+ assert(263+ alerts.some((alert) => alert.code === "peer-key-changed"),264+ "a substituted directory key must surface peer-key-changed",265+ );266+ assert(getSharedFieldKey(scope, 3) === null, "a substituted key must install nothing");267+ stop();268+ } finally {269+ clearFingerprintPins("watch-app");270+ clearSharedFieldKeys();271+ clearSharedFieldIdentity();272+ }273+});
M package/schema/encrypted.ts +11 -1
M package/schema/mod.ts +28 -0
@@ -38,6 +38,7 @@ import {
3838 mergeNestedPermissions,3939 } from "./nested.ts";4040 import { plain, privateTable } from "./private-table.ts";41+import { sharedEncryptedJson, sharedEncryptedText } from "./shared-encrypted.ts";4142 4243 /**4344 * The curated schema DSL type. Every Jazz member is the pinned Jazz 2
@@ -88,6 +89,8 @@ export type SchemaDsl =
8889 encryptedDate: typeof encryptedDate;8990 privateTable: typeof privateTable;9091 plain: typeof plain;92+ sharedEncryptedText: typeof sharedEncryptedText;93+ sharedEncryptedJson: typeof sharedEncryptedJson;9194 mutation: typeof mutation;9295 effect: typeof effect;9396 log: typeof log;
@@ -136,6 +139,8 @@ export const s: SchemaDsl = {
136139 encryptedDate,137140 privateTable,138141 plain,142+ sharedEncryptedText,143+ sharedEncryptedJson,139144 mutation,140145 effect,141146 log,
@@ -151,6 +156,29 @@ export {
151156 type PrivateTableColumns,152157 } from "./private-table.ts";153158 159+export {160+ type SharedColumnOptions,161+ sharedEncryptedJson,162+ sharedEncryptedText,163+ sharedFieldReady,164+ type SharedFieldValue,165+ unwrapSharedField,166+} from "./shared-encrypted.ts";167+export { SharedFieldError } from "./shared-crypto.ts";168+export {169+ clearSharedFieldKeys,170+ getSharedFieldKey,171+ installSharedFieldKey,172+ latestSharedFieldGeneration,173+ sharedKeyScope,174+ subscribeSharedKeyring,175+} from "./shared-keyring.ts";176+export {177+ clearSharedColumnRegistry,178+ type SharedColumnConfig,179+ sharedColumnConfigs,180+} from "./shared-registry.ts";181+154182 export {155183 clearEncryptedColumnKey,156184 clearEncryptedColumnRegistry,
A package/schema/shared-encrypted.ts +142 -0
@@ -0,0 +1,142 @@
1+/**2+ * Shared encrypted columns: fields readable by every member of a group,3+ * sealed from the sync server. The stored value is the `encs1.` format from4+ * the crypto core — scope and generation ride the string, and the5+ * synchronous read transform resolves the field key from the in-memory6+ * keyring.7+ *8+ * Reads are state-valued, never throwing for a missing key: a thrown9+ * transform fails the whole query, and key-pending is the normal state of a10+ * freshly added member whose wrap has not arrived. The value surfaces as11+ * `ready`, `pending-key`, or `corrupt`, and live queries re-materialize when12+ * the keyring changes.13+ *14+ * Writes are sealed by the mutation layer, which sees the whole row and15+ * resolves the group scope from the configured sibling column; the column's16+ * own write transform only guards — a write that reaches it unsealed throws17+ * rather than storing plaintext, so raw engine access fails closed.18+ *19+ * @module20+ */21+import { schema } from "jazz-tools";22+import { type EncryptedColumn, markEncryptedColumn } from "./encrypted.ts";23+import { openSharedValue, parseSharedValue, SharedFieldError } from "./shared-crypto.ts";24+import { getSharedFieldKey, sharedKeyScope } from "./shared-keyring.ts";25+import { registerSharedColumn, type SharedColumnConfig } from "./shared-registry.ts";26+27+/** The read state of a shared encrypted column. */28+export type SharedFieldValue<T> =29+ | { state: "ready"; value: T }30+ | { state: "pending-key"; scope: string; generation: number }31+ | { state: "corrupt"; code: "corrupt" | "unscoped-write" };32+33+/** Options wiring a shared column to its group, keys, and directory tables. */34+export type SharedColumnOptions = Omit<SharedColumnConfig, "label">;35+36+/** Whether a shared field value holds decrypted content. */37+export function sharedFieldReady<T>(38+ value: SharedFieldValue<T>,39+): value is { state: "ready"; value: T } {40+ return value.state === "ready";41+}42+43+/**44+ * The decrypted content of a shared field, for callers who prefer an45+ * exception over a state check. Throws `key-pending` while the wrap is in46+ * flight and `corrupt` for failed authentication.47+ */48+export function unwrapSharedField<T>(value: SharedFieldValue<T>): T {49+ if (value.state === "ready") return value.value;50+ if (value.state === "pending-key") {51+ throw new SharedFieldError(52+ "key-pending",53+ `the field key for ${value.scope} generation ${value.generation} has not arrived`,54+ );55+ }56+ throw new SharedFieldError(value.code, "the shared field failed authentication");57+}58+59+const SEALED_PREFIX = "encs1.";60+61+function openStored<T>(62+ label: string,63+ stored: string,64+ decode: (plaintext: string) => T,65+): SharedFieldValue<T> {66+ const parsed = parseSharedValue(stored);67+ if (parsed === null) return { state: "corrupt", code: "corrupt" };68+ const scope = sharedKeyScope(parsed.scope.groupTable, parsed.scope.groupId);69+ const fieldKey = getSharedFieldKey(scope, parsed.scope.generation);70+ if (fieldKey === null) {71+ return { state: "pending-key", scope, generation: parsed.scope.generation };72+ }73+ try {74+ return { state: "ready", value: decode(openSharedValue({ stored, fieldKey, label })) };75+ } catch {76+ return { state: "corrupt", code: "corrupt" };77+ }78+}79+80+function guardedWrite(label: string, value: unknown): string {81+ // The mutation layer seals before the engine runs this transform; a value82+ // arriving unsealed means the write bypassed the framework. Failing closed83+ // here is what guarantees plaintext never reaches the store.84+ if (typeof value === "string" && value.startsWith(SEALED_PREFIX)) return value;85+ throw new SharedFieldError(86+ "unscoped-write",87+ `shared column "${label}" only accepts values sealed by the lofi write path — ` +88+ "write through the store or a declared verb",89+ );90+}91+92+function sharedColumn<T>(93+ label: string,94+ options: SharedColumnOptions,95+ decode: (plaintext: string) => T,96+): EncryptedColumn<SharedFieldValue<T>> {97+ registerSharedColumn({ label, ...options });98+ const built = schema.string().transform({99+ to: (value: unknown) => guardedWrite(label, value),100+ from: (value: string) => openStored(label, value, decode),101+ });102+ markEncryptedColumn(built, label);103+ return built as unknown as EncryptedColumn<SharedFieldValue<T>>;104+}105+106+/**107+ * A text column sealed under a group field key: every member holding the108+ * key reads it; the store operator never does. `label` is the column's109+ * cryptographic identity (`"table.column"`), and the options name the group110+ * table, the sibling column referencing the group, the wrapped-key table,111+ * and the key directory.112+ *113+ * ```ts114+ * docs: s.table({115+ * workspaceId: s.ref("workspaces"),116+ * body: s.sharedEncryptedText("docs.body", {117+ * group: "workspaces",118+ * groupIdColumn: "workspaceId",119+ * keys: "workspaceFieldKeys",120+ * directory: "keyDirectory",121+ * }),122+ * }),123+ * ```124+ */125+export function sharedEncryptedText(126+ label: string,127+ options: SharedColumnOptions,128+): EncryptedColumn<SharedFieldValue<string>> {129+ return sharedColumn(label, options, (plaintext) => plaintext);130+}131+132+/**133+ * A JSON-value column sealed under a group field key; the ready state holds134+ * the parsed value. Same label and option semantics as135+ * {@link sharedEncryptedText}.136+ */137+export function sharedEncryptedJson<T = unknown>(138+ label: string,139+ options: SharedColumnOptions,140+): EncryptedColumn<SharedFieldValue<T>> {141+ return sharedColumn(label, options, (plaintext) => JSON.parse(plaintext) as T);142+}
M package/schema/shared-keyring.ts +68 -0
@@ -60,3 +60,71 @@ export function sharedFieldIdentityOrNull(): SharedFieldIdentity | null {
6060 export function clearSharedFieldIdentity(): void {6161 identity = null;6262 }63+64+// --- Field keys --------------------------------------------------------------65+//66+// Unwrapped group field keys, keyed by scope ("groupTable/groupId") and67+// generation. The runtime's wrapped-key watcher installs entries as wraps68+// arrive and sync; the synchronous column transforms read them here. A read69+// whose (scope, generation) is absent surfaces as a pending state, never an70+// exception — key-pending is the normal condition of a freshly added member.71+72+const fieldKeys = new Map<string, Map<number, Uint8Array>>();73+const keyringListeners = new Set<() => void>();74+75+/** The keyring scope of a group resource: `"groupTable/groupId"`. */76+export function sharedKeyScope(groupTable: string, groupId: string): string {77+ return `${groupTable}/${groupId}`;78+}79+80+/** Installs an unwrapped field key and notifies keyring subscribers. */81+export function installSharedFieldKey(82+ scope: string,83+ generation: number,84+ key: Uint8Array,85+): void {86+ if (key.length !== 32) {87+ throw new SharedFieldError("wrap-invalid", "a field key must be 32 bytes");88+ }89+ let generations = fieldKeys.get(scope);90+ if (generations === undefined) {91+ generations = new Map();92+ fieldKeys.set(scope, generations);93+ }94+ const known = generations.get(generation);95+ generations.set(generation, new Uint8Array(key));96+ // Re-notification is what turns pending reads into plaintext; skip only97+ // when the exact key was already present.98+ if (known === undefined || !known.every((byte, index) => byte === key[index])) {99+ for (const listener of [...keyringListeners]) listener();100+ }101+}102+103+/** The installed key for a scope and generation, or null while pending. */104+export function getSharedFieldKey(scope: string, generation: number): Uint8Array | null {105+ return fieldKeys.get(scope)?.get(generation) ?? null;106+}107+108+/** The newest generation installed for a scope, or null when none is. */109+export function latestSharedFieldGeneration(scope: string): number | null {110+ const generations = fieldKeys.get(scope);111+ if (generations === undefined || generations.size === 0) return null;112+ return Math.max(...generations.keys());113+}114+115+/**116+ * Subscribes to keyring changes. Live-query stores resubscribe on change so117+ * rows previously surfaced as pending re-materialize into plaintext.118+ */119+export function subscribeSharedKeyring(listener: () => void): () => void {120+ keyringListeners.add(listener);121+ return () => {122+ keyringListeners.delete(listener);123+ };124+}125+126+/** Empties every installed field key; tests and logout call this. */127+export function clearSharedFieldKeys(): void {128+ fieldKeys.clear();129+ for (const listener of [...keyringListeners]) listener();130+}
A package/schema/shared-registry.ts +45 -0
@@ -0,0 +1,45 @@
1+/**2+ * The shared-column registry: `s.sharedEncrypted*` constructors record their3+ * configuration here at schema-definition time, and the runtime reads it at4+ * boot to start the wrapped-key watcher and to seal writes with the right5+ * scope. Module-level for the same reason the encrypted-column registry is —6+ * schema definition happens before any runtime exists.7+ *8+ * @module9+ */10+11+/** The wiring one shared column declares: where its group and keys live. */12+export type SharedColumnConfig = {13+ /** The column's cryptographic label, conventionally `"table.column"`. */14+ label: string;15+ /** The group table whose membership scopes the field key. */16+ group: string;17+ /** The sibling column on the row that references the group. */18+ groupIdColumn: string;19+ /** The wrapped-key table (declared with `sharedFieldKeyTable`). */20+ keys: string;21+ /** The key-directory table (declared with `sharedFieldDirectoryTable`). */22+ directory: string;23+};24+25+const configs = new Map<string, SharedColumnConfig>();26+27+/** Records one shared column's wiring; keyed by label. */28+export function registerSharedColumn(config: SharedColumnConfig): void {29+ configs.set(config.label, config);30+}31+32+/** Every registered shared-column configuration. */33+export function sharedColumnConfigs(): SharedColumnConfig[] {34+ return [...configs.values()];35+}36+37+/** The registered configuration for a label, if any. */38+export function sharedColumnConfig(label: string): SharedColumnConfig | undefined {39+ return configs.get(label);40+}41+42+/** Empties the registry; tests call this between schemas. */43+export function clearSharedColumnRegistry(): void {44+ configs.clear();45+}
A package/schema/shared_encrypted_test.ts +143 -0
@@ -0,0 +1,143 @@
1+// Shared-column contract: state-valued reads over the keyring (pending →2+// ready without redefinition), the unscoped-write guard, and keyring3+// bookkeeping.4+import {5+ clearSharedFieldKeys,6+ getSharedFieldKey,7+ installSharedFieldKey,8+ latestSharedFieldGeneration,9+ sharedKeyScope,10+ subscribeSharedKeyring,11+} from "./shared-keyring.ts";12+import { generateFieldKey, sealSharedValue, SharedFieldError } from "./shared-crypto.ts";13+import {14+ sharedEncryptedText,15+ sharedFieldReady,16+ type SharedFieldValue,17+ unwrapSharedField,18+} from "./shared-encrypted.ts";19+import { clearSharedColumnRegistry, sharedColumnConfigs } from "./shared-registry.ts";20+import { assert } from "../runtime/test-assert.ts";21+22+const OPTIONS = {23+ group: "workspaces",24+ groupIdColumn: "workspaceId",25+ keys: "workspaceFieldKeys",26+ directory: "keyDirectory",27+} as const;28+29+function transformOf(column: unknown): {30+ to: (value: unknown) => string;31+ from: (value: string) => unknown;32+} {33+ const transform = (column as { _transform?: unknown })._transform;34+ assert(typeof transform === "object" && transform !== null, "column carries no transform");35+ return transform as { to: (value: unknown) => string; from: (value: string) => unknown };36+}37+38+Deno.test("the keyring installs, reports generations, and notifies", () => {39+ clearSharedFieldKeys();40+ const scope = sharedKeyScope("workspaces", "ws-1");41+ try {42+ assert(getSharedFieldKey(scope, 1) === null, "no key before install");43+ assert(latestSharedFieldGeneration(scope) === null, "no generation before install");44+ let notified = 0;45+ const stop = subscribeSharedKeyring(() => notified += 1);46+ const key = generateFieldKey();47+ installSharedFieldKey(scope, 1, key);48+ installSharedFieldKey(scope, 3, generateFieldKey());49+ assert(notified === 2, `install must notify (saw ${notified})`);50+ installSharedFieldKey(scope, 1, key);51+ assert(notified === 2, "an identical re-install must not re-notify");52+ assert(latestSharedFieldGeneration(scope) === 3, "latest generation must win");53+ assert(getSharedFieldKey(scope, 1) !== null, "installed keys must resolve");54+ stop();55+ } finally {56+ clearSharedFieldKeys();57+ }58+});59+60+Deno.test("reads surface pending, then ready once the key installs", () => {61+ clearSharedFieldKeys();62+ clearSharedColumnRegistry();63+ try {64+ const column = transformOf(sharedEncryptedText("docs.body", OPTIONS));65+ const fieldKey = generateFieldKey();66+ const stored = sealSharedValue({67+ plaintext: "shared content",68+ fieldKey,69+ label: "docs.body",70+ scope: { groupTable: "workspaces", groupId: "ws-1", generation: 2 },71+ });72+73+ const pending = column.from(stored) as SharedFieldValue<string>;74+ assert(75+ pending.state === "pending-key" && pending.generation === 2,76+ `a keyless read must surface pending (saw ${JSON.stringify(pending)})`,77+ );78+ let threw = false;79+ try {80+ unwrapSharedField(pending);81+ } catch (error) {82+ threw = error instanceof SharedFieldError && error.code === "key-pending";83+ }84+ assert(threw, "unwrapSharedField must throw key-pending while pending");85+86+ installSharedFieldKey(sharedKeyScope("workspaces", "ws-1"), 2, fieldKey);87+ const ready = column.from(stored) as SharedFieldValue<string>;88+ assert(89+ sharedFieldReady(ready) && ready.value === "shared content",90+ "the same stored value must decrypt after key install",91+ );92+ assert(unwrapSharedField(ready) === "shared content", "unwrap must return the value");93+94+ // The wrong key surfaces corrupt, never garbage.95+ installSharedFieldKey(sharedKeyScope("workspaces", "ws-1"), 2, generateFieldKey());96+ const corrupt = column.from(stored) as SharedFieldValue<string>;97+ assert(corrupt.state === "corrupt", "a wrong key must surface corrupt");98+ const unprefixed = column.from("plaintext leftovers") as SharedFieldValue<string>;99+ assert(unprefixed.state === "corrupt", "a non-sealed stored value must surface corrupt");100+ } finally {101+ clearSharedFieldKeys();102+ clearSharedColumnRegistry();103+ }104+});105+106+Deno.test("writes pass sealed values and refuse everything else", () => {107+ clearSharedColumnRegistry();108+ try {109+ const column = transformOf(sharedEncryptedText("docs.note", OPTIONS));110+ const fieldKey = generateFieldKey();111+ const sealed = sealSharedValue({112+ plaintext: "x",113+ fieldKey,114+ label: "docs.note",115+ scope: { groupTable: "workspaces", groupId: "ws-1", generation: 1 },116+ });117+ assert(column.to(sealed) === sealed, "sealed values must pass through");118+ let refused = false;119+ try {120+ column.to("raw plaintext");121+ } catch (error) {122+ refused = error instanceof SharedFieldError && error.code === "unscoped-write";123+ }124+ assert(refused, "an unsealed write must refuse with unscoped-write");125+ } finally {126+ clearSharedColumnRegistry();127+ }128+});129+130+Deno.test("constructors register their wiring for the runtime", () => {131+ clearSharedColumnRegistry();132+ try {133+ sharedEncryptedText("docs.registered", OPTIONS);134+ const configs = sharedColumnConfigs();135+ assert(136+ configs.length === 1 && configs[0].label === "docs.registered" &&137+ configs[0].keys === "workspaceFieldKeys" && configs[0].directory === "keyDirectory",138+ "the constructor must record its configuration",139+ );140+ } finally {141+ clearSharedColumnRegistry();142+ }143+});