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 / runtime / shared-field-write.ts
shared-field-write.ts 4.5K
1/**2 * The write side of shared encrypted columns: the mutation layer sees whole3 * rows, so it — not the column transform — resolves each shared column's4 * group scope from the configured sibling column, picks the newest field key5 * this device holds, and seals the value before anything is journaled or6 * handed to the engine. The column transform then only verifies the sealed7 * prefix, so a write that bypasses this path fails closed instead of storing8 * plaintext.9 *10 * @module11 */12import { encryptedColumnsOf } from "../schema/encrypted.ts";13import { sealSharedValue, SharedFieldError } from "../schema/shared-crypto.ts";14import {15 getSharedFieldKey,16 latestSharedFieldGeneration,17 sharedKeyScope,18} from "../schema/shared-keyring.ts";19import { sharedColumnConfig } from "../schema/shared-registry.ts";2021const SEALED_PREFIX = "encs1.";2223type SharedWrite = {24 column: string;25 label: string;26 kind: "text" | "json";27 group: string;28 groupIdColumn: string;29};3031function sharedWritesOf(tableName: string, values: Record<string, unknown>): SharedWrite[] {32 const encrypted = encryptedColumnsOf(tableName);33 if (encrypted === undefined) return [];34 const writes: SharedWrite[] = [];35 for (const [column, label] of encrypted) {36 if (!(column in values)) continue;37 const config = sharedColumnConfig(label);38 if (config === undefined) continue;39 const value = values[column];40 // Already sealed (a replayed journal entry or caller-provided seal).41 if (typeof value === "string" && value.startsWith(SEALED_PREFIX)) continue;42 writes.push({43 column,44 label,45 kind: config.kind,46 group: config.group,47 groupIdColumn: config.groupIdColumn,48 });49 }50 return writes;51}5253function sealOne(54 write: SharedWrite,55 groupId: unknown,56 value: unknown,57): string {58 if (typeof groupId !== "string" || !groupId) {59 throw new SharedFieldError(60 "unscoped-write",61 `shared column "${write.label}" needs the row's "${write.groupIdColumn}" to resolve its ` +62 "key scope — include it in the write",63 );64 }65 const scope = sharedKeyScope(write.group, groupId);66 const generation = latestSharedFieldGeneration(scope);67 if (generation === null) {68 throw new SharedFieldError(69 "key-pending",70 `no field key for ${scope} is installed on this device yet — writing waits for the ` +71 "group key to arrive",72 );73 }74 const fieldKey = getSharedFieldKey(scope, generation);75 if (fieldKey === null) {76 throw new SharedFieldError("key-pending", `the field key for ${scope} is not installed`);77 }78 const plaintext = write.kind === "json" ? JSON.stringify(value) : String(value);79 return sealSharedValue({80 plaintext,81 fieldKey,82 label: write.label,83 scope: { groupTable: write.group, groupId, generation },84 });85}8687/** Whether a write touches any shared column that still needs sealing. */88export function hasSharedColumnWrites(89 tableName: string,90 values: Record<string, unknown>,91): boolean {92 return sharedWritesOf(tableName, values).length > 0;93}9495/**96 * Seals every shared column present in a write, resolving the group id from97 * the values themselves. Throws `unscoped-write` when a shared column is98 * written without its group column — the synchronous path cannot fetch the99 * row; include the group column in the patch or use the async variant.100 */101export function sealSharedColumnValuesSync(102 tableName: string,103 values: Record<string, unknown>,104): Record<string, unknown> {105 const writes = sharedWritesOf(tableName, values);106 if (writes.length === 0) return values;107 const sealed = { ...values };108 for (const write of writes) {109 sealed[write.column] = sealOne(write, values[write.groupIdColumn], values[write.column]);110 }111 return sealed;112}113114/**115 * Seals every shared column present in a write, fetching the existing row116 * for updates whose patch omits the group column.117 */118export async function sealSharedColumnValues(119 tableName: string,120 values: Record<string, unknown>,121 fetchRow?: () => Promise<Record<string, unknown> | null>,122): Promise<Record<string, unknown>> {123 const writes = sharedWritesOf(tableName, values);124 if (writes.length === 0) return values;125 const sealed = { ...values };126 let row: Record<string, unknown> | null | undefined;127 for (const write of writes) {128 let groupId = values[write.groupIdColumn];129 if ((typeof groupId !== "string" || !groupId) && fetchRow) {130 row ??= await fetchRow();131 groupId = row?.[write.groupIdColumn];132 }133 sealed[write.column] = sealOne(write, groupId, values[write.column]);134 }135 return sealed;136}