clone

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

fix/175-template-quality-pass
lofi / package / schema / shared-encrypted.ts
shared-encrypted.ts 5.4K
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 */21import { schema } from "jazz-tools";22import { type EncryptedColumn, markEncryptedColumn } from "./encrypted.ts";23import { openSharedValue, parseSharedValue, SharedFieldError } from "./shared-crypto.ts";24import { getSharedFieldKey, sharedKeyScope } from "./shared-keyring.ts";25import { registerSharedColumn, type SharedColumnConfig } from "./shared-registry.ts";2627/** The read state of a shared encrypted column. */28export type SharedFieldValue<T> =29 | { state: "ready"; value: T }30 | { state: "pending-key"; scope: string; generation: number }31 | { state: "corrupt"; code: "corrupt" | "unscoped-write" };3233/** Options wiring a shared column to its group, keys, and directory tables. */34export type SharedColumnOptions = Omit<SharedColumnConfig, "label" | "kind">;3536/** Whether a shared field value holds decrypted content. */37export function sharedFieldReady<T>(38 value: SharedFieldValue<T>,39): value is { state: "ready"; value: T } {40 return value.state === "ready";41}4243/**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 */48export 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}5859const SEALED_PREFIX = "encs1.";6061function 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}7980function 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}9192function sharedColumn<T>(93 label: string,94 options: SharedColumnOptions,95 kind: "text" | "json",96 decode: (plaintext: string) => T,97): EncryptedColumn<SharedFieldValue<T>> {98 registerSharedColumn({ label, kind, ...options });99 const built = schema.string().transform({100 to: (value: unknown) => guardedWrite(label, value),101 from: (value: string) => openStored(label, value, decode),102 });103 markEncryptedColumn(built, label);104 return built as unknown as EncryptedColumn<SharedFieldValue<T>>;105}106107/**108 * A text column sealed under a group field key: every member holding the109 * key reads it; the store operator never does. `label` is the column's110 * cryptographic identity (`"table.column"`), and the options name the group111 * table, the sibling column referencing the group, the wrapped-key table,112 * and the key directory.113 *114 * ```ts115 * docs: s.table({116 * workspaceId: s.ref("workspaces"),117 * body: s.sharedEncryptedText("docs.body", {118 * group: "workspaces",119 * groupIdColumn: "workspaceId",120 * keys: "workspaceFieldKeys",121 * directory: "keyDirectory",122 * }),123 * }),124 * ```125 */126export function sharedEncryptedText(127 label: string,128 options: SharedColumnOptions,129): EncryptedColumn<SharedFieldValue<string>> {130 return sharedColumn(label, options, "text", (plaintext) => plaintext);131}132133/**134 * A JSON-value column sealed under a group field key; the ready state holds135 * the parsed value. Same label and option semantics as136 * {@link sharedEncryptedText}.137 */138export function sharedEncryptedJson<T = unknown>(139 label: string,140 options: SharedColumnOptions,141): EncryptedColumn<SharedFieldValue<T>> {142 return sharedColumn(label, options, "json", (plaintext) => JSON.parse(plaintext) as T);143}