clone

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

feat(schema): s.privateTable — encrypt-by-default tables with s.plain opt-out (#157)

FelineStateMachine committed 21ae011
privateTable seals every column by default, deriving labels from the
table's prefix, and inverts the authoring posture from encrypt-the-
sensitive to expose-what-the-server-needs. Reference columns stay
plaintext automatically (a foreign key the server cannot read cannot
join or gate), byte columns stay plaintext with a report, and s.plain
marks a deliberate plaintext column at the column it affects.

Optionals, defaults, non-lww merge strategies, and transforms on sealed
columns are configuration errors: each would leak plaintext below the
seal boundary or silently discard author intent. Engine conformance
covers the sealed round trip, the plain column's filterability, and the
zero-match plaintext probe.
commit
21ae01119d013fff6f30549ed82a569d34dead56
parent
766ae8e65a7a4a03f9a411c1ac568921bbe58f6f
author
FelineStateMachine <dami.etoile@gmail.com>
7 files changed, +424 -2
Mdeno.json2+-
Mdocs/data-and-ui.md26++++
Mpackage/astro/mod.ts1+
Mpackage/schema/conformance_test.ts46++++++++
Mpackage/schema/mod.ts15++-
Apackage/schema/private-table.ts158+++++++++++++++++++++++++++
Apackage/schema/private_table_test.ts178++++++++++++++++++++++++++++++
M deno.json +1 -1
@@ -63,7 +63,7 @@
6363 "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",6464 "doc": "deno run --allow-run tools/doc_lint.ts",6565 "docs:links": "deno run --allow-read tools/docs_links.ts",66- "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/testing package/tooling apps/reference/tests",66+ "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/testing package/tooling apps/reference/tests",6767 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",6868 "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",6969 "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 docs/data-and-ui.md +26 -0
@@ -156,6 +156,32 @@ later makes existing values unreadable — treat it like a column name. Constrai
156156 merge strategies and transforms cannot operate on ciphertext, and optional stays disabled until157157 the engine's null handling of transformed columns is pinned.158158 159+#### Encrypt by default: `s.privateTable`160+161+`s.privateTable(labelPrefix, columns)` inverts the posture from "encrypt what is sensitive" to162+"expose what the server needs": every column seals by default, and plaintext is the explicit choice.163+Labels derive as `"prefix.column"` — treat the prefix like the table name and never reuse it across164+tables.165+166+```ts167+notes: s.privateTable("notes", {168+ body: s.string(), // sealed: encryptedText("notes.body")169+ score: s.int(), // sealed: encryptedNumber("notes.score") — no i32 limit170+ reviewedAt: s.timestamp(), // sealed: encryptedDate("notes.reviewedAt")171+ meta: s.json(), // sealed: encryptedJson("notes.meta")172+ workspaceId: s.ref("workspaces"), // plaintext: foreign keys stay joinable173+ title: s.plain(s.string()), // plaintext by author choice: a filter target174+}),175+```176+177+Reference columns stay plaintext automatically — a foreign key the server cannot read cannot join or178+gate. `s.plain(column)` marks a deliberate plaintext column (a filter, sort key, or policy target)179+at the column it affects. Byte columns stay plaintext with a report — store byte payloads as base64180+in an encrypted json column to seal them. A column already sealed with an explicit `s.encrypted*`181+keeps its own label. Optionals, defaults, non-lww merge strategies, and transforms on sealed columns182+are configuration errors: each would either leak plaintext below the seal boundary or silently183+discard author intent.184+159185 ## Bind an exact typed query160186 161187 ```ts
M package/astro/mod.ts +1 -0
M package/schema/conformance_test.ts +46 -0
@@ -634,3 +634,49 @@ Deno.test("encrypted columns round-trip through the engine and store only cipher
634634 await testApp.shutdown();635635 }636636 });637+638+// privateTable end-to-end: sealed-by-default columns round-trip through the639+// real engine, the plain-marked column still filters, and the sealed columns640+// registered for the policy guard.641+const privApp = s.defineApp({642+ privateNotes: s.privateTable("privateNotes", {643+ body: s.string(),644+ score: s.int(),645+ title: s.plain(s.string()),646+ }),647+});648+const privPermissions = s.definePermissions(privApp, ({ policy }) => {649+ policy.privateNotes.allowInsert.always();650+ policy.privateNotes.allowRead.always();651+ policy.privateNotes.allowUpdate.always();652+ policy.privateNotes.allowDelete.always();653+});654+655+Deno.test("privateTable seals by default through the engine", async () => {656+ const testApp = await createPolicyTestApp(privApp, privPermissions, expectLike);657+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));658+ try {659+ const db = testApp.as(session("author"));660+ const row = await db.insert(privApp.privateNotes, {661+ body: "sealed by default",662+ score: 2 ** 34,663+ title: "plaintext title",664+ } as never).wait({ tier: "global" });665+ const readBack = (await db.all(privApp.privateNotes.where({ id: row.id })))[0] as {666+ body: string;667+ score: number;668+ title: string;669+ };670+ assert(readBack.body === "sealed by default", "sealed text did not round-trip");671+ assert(readBack.score === 2 ** 34, "sealed number did not round-trip");672+ const byTitle = await db.all(privApp.privateNotes.where({ title: "plaintext title" }));673+ assert(byTitle.length === 1, "the plain-marked column must stay filterable");674+ const byBody = await db.all(675+ privApp.privateNotes.where({ body: "sealed by default" } as never),676+ );677+ assert(byBody.length === 0, "a plaintext filter on a sealed column must match nothing");678+ } finally {679+ clearEncryptedColumnKey();680+ await testApp.shutdown();681+ }682+});
M package/schema/mod.ts +14 -1
@@ -37,6 +37,7 @@ import {
3737 flattenNestedSchema,3838 mergeNestedPermissions,3939 } from "./nested.ts";40+import { plain, privateTable } from "./private-table.ts";4041 4142 /**4243 * The curated schema DSL type. Every Jazz member is the pinned Jazz 2
@@ -85,6 +86,8 @@ export type SchemaDsl =
8586 encryptedJson: typeof encryptedJson;8687 encryptedNumber: typeof encryptedNumber;8788 encryptedDate: typeof encryptedDate;89+ privateTable: typeof privateTable;90+ plain: typeof plain;8891 mutation: typeof mutation;8992 effect: typeof effect;9093 log: typeof log;
@@ -98,7 +101,8 @@ export type SchemaDsl =
98101 * place of a raw `jazz-tools` import; Jazz member names and behavior are99102 * identical to the pinned Jazz 2 DSL. The lofi-owned members: nested100103 * namespaces ({@link defineNestedApp}), sealed columns ({@link encryptedText},101- * {@link encryptedJson}, {@link encryptedNumber}, {@link encryptedDate}), and104+ * {@link encryptedJson}, {@link encryptedNumber}, {@link encryptedDate}, with105+ * {@link privateTable} and {@link plain} for encrypt-by-default tables), and102106 * the verb grammar — {@link mutation} declares103107 * callable verbs over {@link insert}, {@link update}, and {@link remove},104108 * carrying {@link effect} units and {@link log} entries. Verb calls return a
@@ -130,6 +134,8 @@ export const s: SchemaDsl = {
130134 encryptedJson,131135 encryptedNumber,132136 encryptedDate,137+ privateTable,138+ plain,133139 mutation,134140 effect,135141 log,
@@ -138,6 +144,13 @@ export const s: SchemaDsl = {
138144 remove,139145 };140146 147+export {148+ plain,149+ type PlainColumn,150+ privateTable,151+ type PrivateTableColumns,152+} from "./private-table.ts";153+141154 export {142155 clearEncryptedColumnKey,143156 clearEncryptedColumnRegistry,
A package/schema/private-table.ts +158 -0
@@ -0,0 +1,158 @@
1+/**2+ * Encrypt-by-default tables: `privateTable` seals every column unless the3+ * author opts a column out with `plain`, inverting the posture from "encrypt4+ * what is sensitive" to "expose what the server needs". Reference columns5+ * stay plaintext automatically — a foreign key the server cannot read cannot6+ * join or gate — and each sealed column derives its label from the table's7+ * prefix, so labels stay unique and stable per column.8+ *9+ * ```ts10+ * notes: s.privateTable("notes", {11+ * body: s.string(), // sealed as encryptedText("notes.body")12+ * score: s.int(), // sealed as encryptedNumber("notes.score")13+ * at: s.timestamp(), // sealed as encryptedDate("notes.at")14+ * meta: s.json(), // sealed as encryptedJson("notes.meta")15+ * workspaceId: s.ref("workspaces"), // plaintext: foreign keys stay joinable16+ * title: s.plain(s.string()), // plaintext by author choice: a filter target17+ * }),18+ * ```19+ *20+ * @module21+ */22+import {23+ type DefinedTable,24+ schema,25+ type SqlType,26+ type TableDefinition,27+ type TypedColumnBuilder,28+} from "jazz-tools";29+import {30+ type EncryptedColumn,31+ encryptedColumnLabelOf,32+ encryptedDate,33+ encryptedJson,34+ encryptedNumber,35+ encryptedText,36+} from "./encrypted.ts";37+38+declare const plainColumnBrand: unique symbol;39+40+/** A column excluded from sealing by the author; see {@link plain}. */41+export type PlainColumn<T> = T & { readonly [plainColumnBrand]: true };42+43+const PLAIN_COLUMN = Symbol("lofi.plainColumn");44+45+type AnyBuilder = TypedColumnBuilder<SqlType, boolean, string | undefined, boolean, unknown>;46+47+/**48+ * Marks a column of a {@link privateTable} as deliberately plaintext —49+ * because it is a filter, sort key, or permission target the server must50+ * evaluate. The marker is the visible record of that decision at the column51+ * it affects.52+ */53+export function plain<T extends object>(column: T): PlainColumn<T> {54+ Object.defineProperty(column, PLAIN_COLUMN, { value: true });55+ return column as PlainColumn<T>;56+}57+58+type BuilderInternals = {59+ _sqlType: unknown;60+ _nullable?: boolean;61+ _default?: unknown;62+ _mergeStrategy?: string;63+ _transform?: unknown;64+ _references?: string;65+ [PLAIN_COLUMN]?: true;66+};67+68+/**69+ * The column mapping applied by {@link privateTable}: plain-marked columns,70+ * reference columns, and byte columns keep their declared type; every other71+ * column becomes an {@link EncryptedColumn} of its view type.72+ */73+export type PrivateTableColumns<TCols> = {74+ [K in keyof TCols]: TCols[K] extends { readonly [plainColumnBrand]: true } ? TCols[K]75+ : TCols[K] extends { readonly __jazzReferences: infer R } ? (R extends string ? TCols[K]76+ : TCols[K] extends { readonly __jazzSqlType: "BYTEA" } ? TCols[K]77+ : TCols[K] extends { readonly __jazzValue: infer V } ? EncryptedColumn<V>78+ : never)79+ : never;80+};81+82+function sealColumn(labelPrefix: string, name: string, builder: unknown): unknown {83+ const internals = builder as BuilderInternals;84+ if (internals[PLAIN_COLUMN] === true) return builder;85+ if (encryptedColumnLabelOf(builder) !== undefined) return builder;86+ if (internals._references !== undefined) return builder;87+ const label = `${labelPrefix}.${name}`;88+ if (internals._nullable === true) {89+ throw new TypeError(90+ `private table column "${label}" is optional; encrypted columns cannot be optional until ` +91+ "the engine's null handling of transformed columns is pinned — mark it s.plain(...) or " +92+ "drop .optional()",93+ );94+ }95+ if (internals._default !== undefined) {96+ throw new TypeError(97+ `private table column "${label}" declares a default; the engine applies defaults below ` +98+ "the seal boundary, which would store the default as plaintext — set the value at " +99+ "insert time instead",100+ );101+ }102+ if (internals._mergeStrategy !== undefined && internals._mergeStrategy !== "lww") {103+ throw new TypeError(104+ `private table column "${label}" declares the "${internals._mergeStrategy}" merge ` +105+ "strategy, which cannot operate on ciphertext — sealed columns merge last-write-wins",106+ );107+ }108+ if (internals._transform !== undefined) {109+ throw new TypeError(110+ `private table column "${label}" carries a transform, which sealing would discard — ` +111+ "apply the transform in application code around the sealed value, or mark the column " +112+ "s.plain(...)",113+ );114+ }115+ const sql = internals._sqlType;116+ if (sql === "BYTEA") {117+ console.warn(118+ `lofi schema: private table column "${label}" holds bytes and stays plaintext — store ` +119+ "byte payloads as base64 in an encrypted json column to seal them",120+ );121+ return builder;122+ }123+ if (sql === "TEXT") return encryptedText(label);124+ if (sql === "INTEGER" || sql === "REAL") return encryptedNumber(label);125+ if (sql === "TIMESTAMP") return encryptedDate(label);126+ // BOOLEAN, ENUM, JSON, and ARRAY views all round-trip as JSON values.127+ return encryptedJson(label);128+}129+130+/**131+ * Declares a table whose columns are sealed by default. `labelPrefix` is the132+ * cryptographic identity prefix of every sealed column (`"prefix.column"`) —133+ * treat it like the table name and never reuse it across tables. Columns opt134+ * out of sealing only by being reference columns, byte columns (reported),135+ * or wrapped in {@link plain}; optionals, defaults, non-lww merge136+ * strategies, and transforms on sealed columns are configuration errors.137+ */138+export function privateTable<const TCols extends Record<string, AnyBuilder>>(139+ labelPrefix: string,140+ columns: TCols,141+): DefinedTable<142+ // Resolves to the exact mapped columns for every concrete schema; the143+ // fallback branch only exists to satisfy the DefinedTable constraint while144+ // the mapping is still generic.145+ PrivateTableColumns<TCols> extends TableDefinition ? PrivateTableColumns<TCols> : TableDefinition146+> {147+ if (!labelPrefix.trim()) {148+ throw new TypeError("privateTable requires a non-empty label prefix");149+ }150+ const sealed: Record<string, unknown> = {};151+ for (const [name, builder] of Object.entries(columns)) {152+ sealed[name] = sealColumn(labelPrefix, name, builder);153+ }154+ return schema.table(sealed as never) as unknown as DefinedTable<155+ PrivateTableColumns<TCols> extends TableDefinition ? PrivateTableColumns<TCols>156+ : TableDefinition157+ >;158+}
A package/schema/private_table_test.ts +178 -0
@@ -0,0 +1,178 @@
1+// privateTable contract: every column seals by default with a derived label,2+// refs and plain-marked columns stay plaintext, bytes stay plaintext with a3+// report, and configurations the seal boundary cannot honor are refused.4+import { clearEncryptedColumnRegistry, encryptedColumnsOf, s } from "./mod.ts";5+import {6+ clearEncryptedColumnKey,7+ encryptedColumnLabelOf,8+ setEncryptedColumnKey,9+} from "./encrypted.ts";10+import { assert } from "../runtime/test-assert.ts";11+12+function expectTypeError(run: () => unknown, needle: string, context: string): void {13+ try {14+ run();15+ } catch (error) {16+ assert(17+ error instanceof TypeError && error.message.includes(needle),18+ `${context}: expected a TypeError mentioning "${needle}", received ${String(error)}`,19+ );20+ return;21+ }22+ throw new Error(`${context}: expected a refusal, but the call succeeded`);23+}24+25+Deno.test("privateTable seals every column type with derived labels", () => {26+ clearEncryptedColumnRegistry();27+ try {28+ const table = s.privateTable("vault", {29+ body: s.string(),30+ score: s.int(),31+ ratio: s.float(),32+ at: s.timestamp(),33+ done: s.boolean(),34+ kind: s.enum("a", "b"),35+ meta: s.json(),36+ tags: s.array(s.string()),37+ workspaceId: s.ref("workspaces"),38+ title: s.plain(s.string()),39+ });40+ const columns = table.columns as Record<string, unknown>;41+ for (const sealedName of ["body", "score", "ratio", "at", "done", "kind", "meta", "tags"]) {42+ assert(43+ encryptedColumnLabelOf(columns[sealedName]) === `vault.${sealedName}`,44+ `column "${sealedName}" did not seal with the derived label`,45+ );46+ }47+ assert(48+ encryptedColumnLabelOf(columns.workspaceId) === undefined,49+ "a ref column must stay plaintext",50+ );51+ assert(52+ encryptedColumnLabelOf(columns.title) === undefined,53+ "a plain-marked column must stay plaintext",54+ );55+56+ // Defining an app over the table registers the sealed columns.57+ s.defineApp({ vault: table } as never);58+ const registered = encryptedColumnsOf("vault");59+ assert(60+ registered !== undefined && registered.size === 8 &&61+ registered.get("body") === "vault.body",62+ "privateTable columns did not register through defineApp",63+ );64+ } finally {65+ clearEncryptedColumnRegistry();66+ }67+});68+69+Deno.test("privateTable sealed values round-trip through the transforms", () => {70+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));71+ clearEncryptedColumnRegistry();72+ try {73+ const table = s.privateTable("roundtrip", {74+ done: s.boolean(),75+ kind: s.enum("draft", "final"),76+ tags: s.array(s.string()),77+ });78+ const columns = table.columns as Record<string, {79+ _transform?: { to(value: unknown): string; from(value: string): unknown };80+ }>;81+ const cases: ReadonlyArray<readonly [string, unknown]> = [82+ ["done", true],83+ ["kind", "final"],84+ ["tags", ["a", "b"]],85+ ];86+ for (const [name, value] of cases) {87+ const transform = columns[name]._transform;88+ assert(transform !== undefined, `sealed column "${name}" carries no transform`);89+ const stored = transform.to(value);90+ assert(stored.startsWith("enc2."), `sealed column "${name}" stored an unsealed value`);91+ assert(92+ JSON.stringify(transform.from(stored)) === JSON.stringify(value),93+ `sealed column "${name}" did not round-trip`,94+ );95+ }96+ } finally {97+ clearEncryptedColumnKey();98+ clearEncryptedColumnRegistry();99+ }100+});101+102+Deno.test("privateTable refuses configurations the seal boundary cannot honor", () => {103+ clearEncryptedColumnRegistry();104+ try {105+ expectTypeError(106+ () => s.privateTable("bad", { note: s.string().optional() }),107+ "optional",108+ "optional column",109+ );110+ expectTypeError(111+ () => s.privateTable("bad", { note: s.string().default("x") }),112+ "default",113+ "defaulted column",114+ );115+ // The alpha.53 legacy signatures degrade .merge()/.transform() to the116+ // untyped builder, hence the casts (see the modifier note in mod.ts).117+ expectTypeError(118+ () => s.privateTable("bad", { tags: s.array(s.string()).merge("g-set") as never }),119+ "merge",120+ "non-lww merge",121+ );122+ expectTypeError(123+ () =>124+ s.privateTable("bad", {125+ csv: s.string().transform({126+ from: (value: string) => value.split(","),127+ to: (value: string[]) => value.join(","),128+ }) as never,129+ }),130+ "transform",131+ "transformed column",132+ );133+ expectTypeError(() => s.privateTable(" ", { note: s.string() }), "prefix", "empty prefix");134+ } finally {135+ clearEncryptedColumnRegistry();136+ }137+});138+139+Deno.test("privateTable passes through author-sealed and byte columns", () => {140+ clearEncryptedColumnRegistry();141+ try {142+ const explicit = s.encryptedText("custom.label");143+ const table = s.privateTable("mixed", {144+ note: explicit,145+ raw: s.bytes(),146+ });147+ const columns = table.columns as Record<string, unknown>;148+ assert(149+ encryptedColumnLabelOf(columns.note) === "custom.label",150+ "an author-sealed column must keep its own label",151+ );152+ assert(153+ encryptedColumnLabelOf(columns.raw) === undefined,154+ "a bytes column must stay plaintext (reported, not sealed)",155+ );156+ } finally {157+ clearEncryptedColumnRegistry();158+ }159+});160+161+// Compile-time pins for the column mapping.162+const typed = s.privateTable("typedvault", {163+ body: s.string(),164+ workspaceId: s.ref("workspaces"),165+ title: s.plain(s.string()),166+});167+clearEncryptedColumnRegistry();168+const typedApp = s.defineApp({ workspaces: s.table({ name: s.string() }), typedvault: typed });169+clearEncryptedColumnRegistry();170+171+Deno.test("privateTable type mapping: sealed columns lose where, plain and refs keep it", () => {172+ // Plain-marked and ref columns remain filterable.173+ const byTitle = typedApp.typedvault.where({ title: "x" });174+ const byRef = typedApp.typedvault.where({ workspaceId: "some-id" });175+ assert(byTitle !== undefined && byRef !== undefined, "plaintext filters must stay expressible");176+ // @ts-expect-error — a sealed column is excluded from where177+ typedApp.typedvault.where({ body: "x" });178+});