clone

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

feat(schema): compile-time and policy guards for encrypted columns (#155)

FelineStateMachine committed ea54b70
Encrypted columns now carry a branded EncryptedColumn type whose phantom
stored sql type collapses every filter position to never, so a where on an
encrypted column is a compile error; the chaining modifiers are disabled
the same way. The define entry points record encrypted columns into a
registry, and the access layer refuses templates and raw policy rules
that reference one — such a policy would compile but silently never match.

matchDecrypted names the client-side answer: rows reaching the caller are
already decrypted, so predicates run locally after plaintext narrowing.

A type-level test suite pins the branding mechanism against the engine's
where-input mapping and doubles as the canary for engine bumps.
commit
ea54b703e4889e780cedb2dddeddf40f293a6074
parent
ee51c16f8ccbe3c9bc82ffc15febb3cb2e0b85b8
author
FelineStateMachine <dami.etoile@gmail.com>
11 files changed, +492 -32
Mdeno.json2+-
Mdocs/data-and-ui.md15++-
Mdocs/threat-model.md10+-
Mpackage/access/access_security_test.ts71+++++++++++++
Mpackage/access/policies.ts79++++++++++++++-
Mpackage/schema/encrypted.ts168+++++++++++++++++++++++++++---
Mpackage/schema/encrypted_test.ts40+++++++
Apackage/schema/encrypted_types_test.ts63+++++++++++
Mpackage/schema/mod.ts28+++++-
Mpackage/schema/mod_test.ts46++++++++-
Mpackage/schema/nested.ts2+
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/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/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 +12 -3
@@ -143,9 +143,18 @@ later makes existing values unreadable — treat it like a column name. Constrai
143143 - **Account-private only, for now.** The key derives from the account secret, so every device144144 holding the account decrypts and nobody else does — including other members of a shared group. A145145 row read under a different account throws `EncryptedColumnError` instead of returning garbage.146-- **Not filterable, not policy-visible.** `where` on an encrypted column compares ciphertext and147- matches nothing useful, and a permission policy must not reference one — the server cannot148- evaluate what it cannot read. Filter and gate on plaintext columns beside it.146+- **Not filterable, not policy-visible — enforced.** A `where` on an encrypted column is a compile147+ error (the column type is excluded from filter positions), and a permission policy referencing one148+ fails configuration with `AccessError` — the server cannot evaluate what it cannot read. Gate on149+ plaintext columns beside it. For filtering, rows reaching your code are already decrypted, so150+ match them client-side with `matchDecrypted` from `@nzip/lofi/schema`: narrow the query with151+ plaintext filters first, then apply the predicate, and only then any limit (a `limit()` before the152+ predicate under-fetches). Sort returned rows in code rather than ordering by an encrypted column —153+ ciphertext order is arbitrary.154+- **No chaining modifiers.** `.default()`, `.merge()`, `.transform()`, and `.optional()` are compile155+ errors on encrypted columns: a default would be applied below the seal boundary as plaintext,156+ merge strategies and transforms cannot operate on ciphertext, and optional stays disabled until157+ the engine's null handling of transformed columns is pinned.149158 150159 ## Bind an exact typed query151160
M docs/threat-model.md +6 -4
M package/access/access_security_test.ts +71 -0
@@ -9,6 +9,7 @@ import {
99 sharedAccess,1010 sharedGrantTable,1111 } from "./mod.ts";12+import { clearEncryptedColumnRegistry, s as lofiSchema } from "../schema/mod.ts";1213 import { assert } from "../runtime/test-assert.ts";1314 1415 const schema = {
@@ -449,3 +450,73 @@ Deno.test("access templates reject malformed relationship tables with actionable
449450 "configuration error was not actionable",450451 );451452 });453+454+Deno.test("policies referencing encrypted columns fail configuration", () => {455+ clearEncryptedColumnRegistry();456+ try {457+ // Template-consumed column: user_id sealed in a grant table. requireColumn458+ // must refuse before the type check ever compares the built schema, where459+ // an encrypted column is indistinguishable TEXT.460+ const sealedGrantApp = lofiSchema.defineApp({461+ sealedDocs: lofiSchema.table({ title: lofiSchema.string() }),462+ sealedDocGrants: lofiSchema.table({463+ resourceId: lofiSchema.ref("sealedDocs"),464+ user_id: lofiSchema.encryptedText("sealedDocGrants.user_id"),465+ can_edit: lofiSchema.boolean(),466+ }),467+ });468+ let templateMessage = "";469+ try {470+ defineAccessPolicies(sealedGrantApp, [471+ sharedAccess({472+ resource: sealedGrantApp.sealedDocs,473+ grants: sealedGrantApp.sealedDocGrants,474+ }),475+ ]);476+ } catch (error) {477+ templateMessage = error instanceof Error ? error.message : String(error);478+ }479+ assert(480+ templateMessage.includes("encrypted") && templateMessage.includes("user_id"),481+ `template with encrypted user_id compiled: ${templateMessage || "no error"}`,482+ );483+484+ // Raw-extension rules: an object condition keyed by an encrypted column485+ // must refuse — such a policy would compile but silently never match.486+ const rawApp = lofiSchema.defineApp({487+ vaultDocs: lofiSchema.table({488+ title: lofiSchema.string(),489+ secret: lofiSchema.encryptedText("vaultDocs.secret"),490+ }),491+ });492+ let objectMessage = "";493+ try {494+ defineAccessPolicies(rawApp, [privateAccess({ resource: rawApp.vaultDocs })], (context) => {495+ context.policy.vaultDocs.allowRead.where({ secret: "visible" });496+ });497+ } catch (error) {498+ objectMessage = error instanceof Error ? error.message : String(error);499+ }500+ assert(501+ objectMessage.includes("encrypted") && objectMessage.includes("secret"),502+ `object condition on encrypted column compiled: ${objectMessage || "no error"}`,503+ );504+505+ // A function condition whose returned literal is keyed by an encrypted506+ // column is checked the same way.507+ let functionMessage = "";508+ try {509+ defineAccessPolicies(rawApp, [privateAccess({ resource: rawApp.vaultDocs })], (context) => {510+ context.policy.vaultDocs.allowUpdate.where(() => ({ secret: "visible" }));511+ });512+ } catch (error) {513+ functionMessage = error instanceof Error ? error.message : String(error);514+ }515+ assert(516+ functionMessage.includes("encrypted") && functionMessage.includes("secret"),517+ `function condition on encrypted column compiled: ${functionMessage || "no error"}`,518+ );519+ } finally {520+ clearEncryptedColumnRegistry();521+ }522+});
M package/access/policies.ts +77 -2
@@ -1,5 +1,6 @@
11 import { schema as s } from "jazz-tools";22 import type { CompiledPermissions } from "jazz-tools";3+import { isEncryptedColumn } from "../schema/encrypted.ts";34 import { AccessError } from "./errors.ts";45 56 /** Minimum declared Jazz table metadata consumed by access templates. */
@@ -127,6 +128,13 @@ function requireColumn(
127128 type: string,128129 references?: string,129130 ): Column {131+ if (isEncryptedColumn(table._table, name)) {132+ throw new AccessError(133+ "configuration",134+ `Access table ${table._table} declares ${name} as an encrypted column; template columns ` +135+ "are evaluated by the server, which cannot read ciphertext. Use a plaintext column.",136+ );137+ }130138 const column = columns(table).find((candidate) => candidate.name === name);131139 if (!column || column.column_type?.type !== type || column.references !== references) {132140 const reference = references ? ` referencing ${references}` : "";
@@ -184,6 +192,69 @@ export type RawAccessPolicyContext = {
184192 /** Callback for app-specific rules that do not fit the built-in templates. */185193 export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;186194 195+// A policy referencing an encrypted column would compile but silently never196+// match — the server compares ciphertext it cannot read — so every rule197+// builder refuses such conditions at compile time. Object conditions are198+// checked directly; function conditions have their returned condition checked199+// against the same table, and any condition they build for another table200+// passes through that table's own guarded builder.201+function assertNoEncryptedConditionKeys(tableName: string, input: unknown): void {202+ if (typeof input !== "object" || input === null || Array.isArray(input)) return;203+ for (const key of Object.keys(input)) {204+ if (key.startsWith("$")) continue;205+ if (isEncryptedColumn(tableName, key)) {206+ throw new AccessError(207+ "configuration",208+ `Policy for ${tableName} filters on encrypted column ${key}; the server cannot ` +209+ "evaluate what it cannot read. Gate on a plaintext column instead.",210+ );211+ }212+ }213+}214+215+function guardedWhere(216+ tableName: string,217+ rule: { where(input: unknown): unknown },218+): (input: unknown) => unknown {219+ return (input: unknown) => {220+ if (typeof input === "function") {221+ return rule.where((row: unknown) => {222+ const condition = (input as (row: unknown) => unknown)(row);223+ assertNoEncryptedConditionKeys(tableName, condition);224+ return condition;225+ });226+ }227+ assertNoEncryptedConditionKeys(tableName, input);228+ return rule.where(input);229+ };230+}231+232+function guardTablePolicy(tableName: string, tablePolicy: TablePolicy): TablePolicy {233+ const guardRule = (rule: RuleBuilder): RuleBuilder => ({234+ where: guardedWhere(tableName, rule),235+ always: () => rule.always(),236+ });237+ return {238+ allowRead: guardRule(tablePolicy.allowRead),239+ allowInsert: guardRule(tablePolicy.allowInsert),240+ allowUpdate: guardRule(tablePolicy.allowUpdate),241+ allowDelete: guardRule(tablePolicy.allowDelete),242+ exists: { where: guardedWhere(tableName, tablePolicy.exists) },243+ };244+}245+246+function guardPolicies(policy: Record<string, TablePolicy>): Record<string, TablePolicy> {247+ return new Proxy(policy, {248+ get(target, property, receiver) {249+ const value = Reflect.get(target, property, receiver);250+ if (typeof property !== "string" || typeof value !== "object" || value === null) {251+ return value;252+ }253+ return guardTablePolicy(property, value as TablePolicy);254+ },255+ });256+}257+187258 /**188259 * Compiles the three narrow templates through Jazz's own policy builder. The189260 * optional callback is the raw Jazz-policy escape hatch for app-specific rules.
@@ -221,8 +292,12 @@ export function defineAccessPolicies<TApp extends object>(
221292 for (const template of templates) validateTemplate(template);222293 223294 return s.definePermissions(app, (jazzContext) => {224- const { policy, session, anyOf, allOf, allowedTo } =225- jazzContext as unknown as RawAccessPolicyContext;295+ const rawContext = jazzContext as unknown as RawAccessPolicyContext;296+ const guarded: RawAccessPolicyContext = {297+ ...rawContext,298+ policy: guardPolicies(rawContext.policy),299+ };300+ const { policy, session, anyOf, allOf, allowedTo } = guarded;226301 for (const template of templates) {227302 if (template.kind === "private") {228303 const resource = policy[template.resource._table];
M package/schema/encrypted.ts +151 -17
@@ -23,10 +23,12 @@
2323 * - The runtime installs the key at boot ({@link setEncryptedColumnKey});2424 * touching an encrypted column before that fails closed — plaintext is2525 * never written because a key was missing.26- * - Filters and policies address the stored representation. A `where` on an27- * encrypted column compares ciphertext (useless, harmless); a permission28- * policy must not reference one — the server cannot evaluate what it29- * cannot read.26+ * - Filters and policies address the stored representation, which is27+ * ciphertext, so both are rejected: a `where` on an encrypted column is a28+ * compile error ({@link EncryptedColumn} collapses the filter position to29+ * `never`), and a permission policy referencing one fails configuration —30+ * the server cannot evaluate what it cannot read. Filter decrypted rows31+ * client-side with {@link matchDecrypted}.3032 * - Reading with the wrong account key (a shared row from another account)3133 * throws {@link EncryptedColumnError}. Encrypted columns belong in3234 * account-private tables; fields shared across accounts need a shared key,
@@ -38,9 +40,41 @@
3840 import { xchacha20poly1305 } from "@noble/ciphers/chacha";3941 import { hkdf } from "@noble/hashes/hkdf";4042 import { sha256 } from "@noble/hashes/sha2";41-import { schema, type StringColumn } from "jazz-tools";43+import { schema, type TypedColumnBuilder } from "jazz-tools";4244 import { padPayload, unpadPayload } from "./padding.ts";4345 46+declare const encryptedColumnBrand: unique symbol;47+48+// The phantom stored type is a union on purpose: the engine's where-input49+// mapping branches on the single stored sql type, so a union matches no50+// branch and every filter position for the column collapses to `never`.51+// Row and insert types read the view type and are unaffected.52+type EncryptedStoredSql = "TEXT" | "BYTEA";53+54+/**55+ * The column type of every `s.encrypted*` constructor. Structurally a valid56+ * table column with the declared view type, but excluded from `where` filters57+ * at compile time — the store holds ciphertext, so a filter could only ever58+ * compare sealed bytes. The chaining modifiers are disabled: a `.default()`59+ * would be applied below the seal boundary as plaintext, `.merge()` semantics60+ * other than last-write-wins cannot operate on ciphertext, `.transform()`61+ * would replace the seal itself, and `.optional()` stays disabled until the62+ * engine's null handling of transformed columns is pinned.63+ */64+export interface EncryptedColumn<TView> extends65+ Omit<66+ TypedColumnBuilder<EncryptedStoredSql, false, undefined, false, TView>,67+ "default" | "merge" | "transform" | "optional"68+ > {69+ readonly [encryptedColumnBrand]?: TView;70+ default(value: never): never;71+ merge(strategy: never): never;72+ transform(transform: never): never;73+ // The `this: never` parameter keeps the method arity-compatible with the74+ // base builder while making every call site a compile error.75+ optional(this: never): never;76+}77+4478 const textEncoder = new TextEncoder();4579 const textDecoder = new TextDecoder();4680
@@ -176,6 +210,106 @@ function open(label: string, stored: string): string {
176210 }177211 }178212 213+// Runtime identity of encrypted columns: the factory tags each builder with214+// its label, and the schema facade's define wrappers harvest the tags into a215+// table/column registry that the access layer consults — the built schema216+// types encrypted columns as ordinary TEXT, so the builders are the only217+// place the distinction exists.218+const ENCRYPTED_LABEL = Symbol("lofi.encryptedColumnLabel");219+220+const registry = new Map<string, Map<string, string>>();221+const labelLocations = new Map<string, string>();222+223+/** The encrypted-column label carried by a builder, if it has one. */224+export function encryptedColumnLabelOf(builder: unknown): string | undefined {225+ if (typeof builder !== "object" || builder === null) return undefined;226+ return (builder as { [ENCRYPTED_LABEL]?: string })[ENCRYPTED_LABEL];227+}228+229+type TableDefinitionLike = {230+ __jazzTableDefinition?: boolean;231+ columns?: Record<string, unknown>;232+};233+234+function tableColumnsOf(definition: unknown): Record<string, unknown> {235+ const like = definition as TableDefinitionLike;236+ if (like && typeof like === "object" && like.__jazzTableDefinition === true && like.columns) {237+ return like.columns;238+ }239+ return (definition ?? {}) as Record<string, unknown>;240+}241+242+/**243+ * Records every encrypted column of a schema definition under its table and244+ * column name. The schema facade's `defineSchema`/`defineApp` wrappers call245+ * this; it is idempotent for a stable definition. A label reused by a second246+ * column is reported: two columns sharing a label share a subkey and their247+ * ciphertext replays across them.248+ */249+export function registerEncryptedColumns(definition: unknown): void {250+ if (typeof definition !== "object" || definition === null) return;251+ for (const [tableName, tableDefinition] of Object.entries(definition)) {252+ for (const [columnName, builder] of Object.entries(tableColumnsOf(tableDefinition))) {253+ const label = encryptedColumnLabelOf(builder);254+ if (label === undefined) continue;255+ const location = `${tableName}.${columnName}`;256+ const existing = labelLocations.get(label);257+ if (existing !== undefined && existing !== location) {258+ console.warn(259+ `lofi schema: encrypted-column label "${label}" is used by both ${existing} and ` +260+ `${location}; shared labels share a subkey, so their ciphertext replays across ` +261+ "the two columns — give each column a unique label",262+ );263+ }264+ labelLocations.set(label, location);265+ let table = registry.get(tableName);266+ if (table === undefined) {267+ table = new Map();268+ registry.set(tableName, table);269+ }270+ table.set(columnName, label);271+ }272+ }273+}274+275+/** Whether the named column of the named table is an encrypted column. */276+export function isEncryptedColumn(tableName: string, columnName: string): boolean {277+ return registry.get(tableName)?.has(columnName) ?? false;278+}279+280+/** The registered encrypted columns of a table, keyed by column name. */281+export function encryptedColumnsOf(tableName: string): ReadonlyMap<string, string> | undefined {282+ return registry.get(tableName);283+}284+285+/** Empties the encrypted-column registry; tests call this between schemas. */286+export function clearEncryptedColumnRegistry(): void {287+ registry.clear();288+ labelLocations.clear();289+}290+291+/**292+ * Filters live-query rows on decrypted values, client-side. Encrypted columns293+ * cannot appear in `where` — the store holds ciphertext — but rows reaching294+ * the caller are already decrypted, so arbitrary predicates run locally:295+ * narrow the query with plaintext filters first, then match here, and only296+ * then apply any limit (a `limit()` before the predicate under-fetches).297+ * Sorting on an encrypted column is the same pattern: sort the returned rows,298+ * never `orderBy` the column (ciphertext order is arbitrary).299+ */300+export function matchDecrypted<T>(rows: readonly T[], predicate: (row: T) => boolean): T[] {301+ return rows.filter(predicate);302+}303+304+function sealedColumn<TView>(305+ label: string,306+ transform: { to(value: TView): string; from(value: string): TView },307+): EncryptedColumn<TView> {308+ const built = schema.string().transform(transform);309+ Object.defineProperty(built, ENCRYPTED_LABEL, { value: label });310+ return built as unknown as EncryptedColumn<TView>;311+}312+179313 /**180314 * A text column sealed on the client before it enters Jazz. `label` is the181315 * column's stable identity, conventionally `"table.column"`; it domain-
@@ -189,22 +323,22 @@ function open(label: string, stored: string): string {
189323 * }),190324 * ```191325 */192-export function encryptedText(label: string): StringColumn<false, false, string> {193- return schema.string().transform({326+export function encryptedText(label: string): EncryptedColumn<string> {327+ return sealedColumn(label, {194328 to: (value: string) => seal(label, value),195329 from: (value: string) => open(label, value),196- }) as unknown as StringColumn<false, false, string>;330+ });197331 }198332 199333 /**200334 * A JSON-value column sealed on the client before it enters Jazz; the view201335 * type is the parsed value. Same label semantics as {@link encryptedText}.202336 */203-export function encryptedJson<T = unknown>(label: string): StringColumn<false, false, T> {204- return schema.string().transform({337+export function encryptedJson<T = unknown>(label: string): EncryptedColumn<T> {338+ return sealedColumn(label, {205339 to: (value: T) => seal(label, JSON.stringify(value)),206340 from: (value: string) => JSON.parse(open(label, value)) as T,207- }) as unknown as StringColumn<false, false, T>;341+ });208342 }209343 210344 /**
@@ -214,8 +348,8 @@ export function encryptedJson<T = unknown>(label: string): StringColumn<false, f
214348 * range round-trip exactly up to double precision. Non-finite values are215349 * rejected at write time.216350 */217-export function encryptedNumber(label: string): StringColumn<false, false, number> {218- return schema.string().transform({351+export function encryptedNumber(label: string): EncryptedColumn<number> {352+ return sealedColumn(label, {219353 to: (value: number) => {220354 if (typeof value !== "number" || !Number.isFinite(value)) {221355 throw new TypeError(`encrypted column "${label}" only stores finite numbers`);
@@ -232,7 +366,7 @@ export function encryptedNumber(label: string): StringColumn<false, false, numbe
232366 }233367 return parsed;234368 },235- }) as unknown as StringColumn<false, false, number>;369+ });236370 }237371 238372 /**
@@ -240,8 +374,8 @@ export function encryptedNumber(label: string): StringColumn<false, false, numbe
240374 * `Date`, matching the plain timestamp column. Same label semantics as241375 * {@link encryptedText}. Invalid dates are rejected at write time.242376 */243-export function encryptedDate(label: string): StringColumn<false, false, Date> {244- return schema.string().transform({377+export function encryptedDate(label: string): EncryptedColumn<Date> {378+ return sealedColumn(label, {245379 to: (value: Date) => {246380 if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {247381 throw new TypeError(`encrypted column "${label}" only stores valid dates`);
@@ -258,5 +392,5 @@ export function encryptedDate(label: string): StringColumn<false, false, Date> {
258392 }259393 return new Date(epochMs);260394 },261- }) as unknown as StringColumn<false, false, Date>;395+ });262396 }
M package/schema/encrypted_test.ts +40 -0
@@ -3,11 +3,16 @@
33 // (versioned prefix, no plaintext in any trivial encoding).44 import {55 clearEncryptedColumnKey,6+ clearEncryptedColumnRegistry,67 EncryptedColumnError,8+ encryptedColumnsOf,79 encryptedDate,810 encryptedJson,911 encryptedNumber,1012 encryptedText,13+ isEncryptedColumn,14+ matchDecrypted,15+ registerEncryptedColumns,1116 setEncryptedColumnKey,1217 } from "./encrypted.ts";1318 import { paddedLength, padPayload, unpadPayload } from "./padding.ts";
@@ -253,3 +258,38 @@ Deno.test(
253258 assert(rejectedInvalidDate, "an invalid date must refuse with TypeError");254259 }),255260 );261+262+Deno.test("the registry records encrypted columns per table and clears", () => {263+ clearEncryptedColumnRegistry();264+ try {265+ registerEncryptedColumns({266+ journal: {267+ title: { _sqlType: "TEXT" },268+ body: encryptedText("journal.body"),269+ mood: encryptedNumber("journal.mood"),270+ },271+ });272+ assert(isEncryptedColumn("journal", "body"), "body must register as encrypted");273+ assert(isEncryptedColumn("journal", "mood"), "mood must register as encrypted");274+ assert(!isEncryptedColumn("journal", "title"), "plaintext column must not register");275+ assert(!isEncryptedColumn("elsewhere", "body"), "other tables must not register");276+ const registered = encryptedColumnsOf("journal");277+ assert(278+ registered?.get("body") === "journal.body" && registered?.get("mood") === "journal.mood",279+ "labels must be retrievable per column",280+ );281+ clearEncryptedColumnRegistry();282+ assert(!isEncryptedColumn("journal", "body"), "clear must empty the registry");283+ } finally {284+ clearEncryptedColumnRegistry();285+ }286+});287+288+Deno.test("matchDecrypted filters rows on decrypted values", () => {289+ const rows = [290+ { id: "a", body: "meeting notes" },291+ { id: "b", body: "grocery list" },292+ ];293+ const matched = matchDecrypted(rows, (row) => row.body.includes("notes"));294+ assert(matched.length === 1 && matched[0].id === "a", "predicate must select by content");295+});
A package/schema/encrypted_types_test.ts +63 -0
@@ -0,0 +1,63 @@
1+// Compile-time contract of encrypted columns, pinned with @ts-expect-error:2+// filter positions collapse to `never`, chaining modifiers are disabled, and3+// row/insert view types survive. This suite is the canary for the branding4+// mechanism — it relies on the engine's where-input mapping being5+// non-distributive over the stored sql type, so an engine bump that changes6+// that shape fails here first.7+import { type EncryptedColumn, s } from "./mod.ts";8+import { clearEncryptedColumnRegistry } from "./encrypted.ts";9+import { assert } from "../runtime/test-assert.ts";10+11+const app = s.defineApp({12+ notes: s.table({13+ title: s.string(),14+ body: s.encryptedText("types.notes.body"),15+ amount: s.encryptedNumber("types.notes.amount"),16+ reviewedAt: s.encryptedDate("types.notes.reviewedAt"),17+ meta: s.encryptedJson<{ tag: string }>("types.notes.meta"),18+ }),19+});20+clearEncryptedColumnRegistry();21+22+Deno.test("encrypted columns are excluded from where at compile time", () => {23+ // Plaintext columns filter as before.24+ const plaintextQuery = app.notes.where({ title: "x" });25+ assert(plaintextQuery !== undefined, "plaintext where must remain expressible");26+27+ // @ts-expect-error — a where on an encrypted text column does not compile28+ app.notes.where({ body: "x" });29+ // @ts-expect-error — a where on an encrypted number column does not compile30+ app.notes.where({ amount: 1 });31+ // @ts-expect-error — a where on an encrypted date column does not compile32+ app.notes.where({ reviewedAt: new Date(0) });33+ // @ts-expect-error — a where on an encrypted json column does not compile34+ app.notes.where({ meta: { tag: "x" } });35+ // @ts-expect-error — operator objects are equally rejected36+ app.notes.where({ body: { contains: "x" } });37+});38+39+Deno.test("encrypted columns reject chaining modifiers at compile time", () => {40+ const column = s.encryptedText("types.chain.column");41+ // @ts-expect-error — a default would be applied below the seal boundary42+ column.default("x");43+ // @ts-expect-error — merge strategies cannot operate on ciphertext44+ column.merge("lww");45+ // @ts-expect-error — a transform would replace the seal itself46+ column.transform({ from: (v: string) => v, to: (v: string) => v });47+ // @ts-expect-error — optional stays disabled until null handling is pinned48+ column.optional();49+ clearEncryptedColumnRegistry();50+});51+52+Deno.test("view types survive the brand", () => {53+ // The view type parameter is what row and insert types read.54+ const text: EncryptedColumn<string> = s.encryptedText("types.view.text");55+ const amount: EncryptedColumn<number> = s.encryptedNumber("types.view.amount");56+ const at: EncryptedColumn<Date> = s.encryptedDate("types.view.at");57+ const json: EncryptedColumn<{ n: number }> = s.encryptedJson<{ n: number }>("types.view.json");58+ assert(59+ [text, amount, at, json].every((column) => typeof column === "object"),60+ "factories must return live builders",61+ );62+ clearEncryptedColumnRegistry();63+});
M package/schema/mod.ts +27 -1
@@ -24,7 +24,13 @@
2424 */2525 import { schema } from "jazz-tools";2626 import { effect, insert, log, mutation, remove, update } from "./effects.ts";27-import { encryptedDate, encryptedJson, encryptedNumber, encryptedText } from "./encrypted.ts";27+import {28+ encryptedDate,29+ encryptedJson,30+ encryptedNumber,31+ encryptedText,32+ registerEncryptedColumns,33+} from "./encrypted.ts";2834 import {2935 defineNestedApp,3036 defineNestedPermissions,
@@ -99,8 +105,23 @@ export type SchemaDsl =
99105 * `WriteHandle`, observed in UI through `useWrite` and `usePendingWrites`100106 * from `@nzip/lofi/preact`.101107 */108+// The define entry points additionally record every encrypted column into109+// the table/column registry the access layer consults; behavior and types110+// are otherwise the pinned originals.111+function withEncryptedColumnRegistration<112+ F extends (definition: never, ...rest: never[]) => unknown,113+>(define: F): F {114+ return ((definition: unknown, ...rest: unknown[]) => {115+ registerEncryptedColumns(definition);116+ return (define as unknown as (...args: unknown[]) => unknown)(definition, ...rest);117+ }) as unknown as F;118+}119+102120 export const s: SchemaDsl = {103121 ...schema,122+ defineSchema: withEncryptedColumnRegistration(schema.defineSchema),123+ defineApp: withEncryptedColumnRegistration(schema.defineApp),124+ defineSliceableApp: withEncryptedColumnRegistration(schema.defineSliceableApp),104125 defineNestedApp,105126 defineNestedPermissions,106127 mergeNestedPermissions,
@@ -119,11 +140,16 @@ export const s: SchemaDsl = {
119140 120141 export {121142 clearEncryptedColumnKey,143+ clearEncryptedColumnRegistry,144+ type EncryptedColumn,122145 EncryptedColumnError,146+ encryptedColumnsOf,123147 encryptedDate,124148 encryptedJson,125149 encryptedNumber,126150 encryptedText,151+ isEncryptedColumn,152+ matchDecrypted,127153 setEncryptedColumnKey,128154 } from "./encrypted.ts";129155 export {
M package/schema/mod_test.ts +42 -4
@@ -1,14 +1,19 @@
11 import { schema } from "jazz-tools";22 import {3+ clearEncryptedColumnRegistry,34 defineNestedApp,45 defineNestedPermissions,56 flattenNestedSchema,7+ isEncryptedColumn,68 mergeNestedPermissions,79 s,810 } from "./mod.ts";911 1012 // The facade re-exports, never reimplements: every curated member must be the11-// pinned Jazz 2 original by identity, so semantics cannot drift.13+// pinned Jazz 2 original by identity, so semantics cannot drift. The define14+// entry points are the one narrowing of that rule: they delegate to the15+// originals unchanged but first record encrypted columns into the registry,16+// so they are asserted as wrappers below rather than as identities here.1217 const curatedMembers = [1318 "string",1419 "boolean",
@@ -24,15 +29,14 @@ const curatedMembers = [
2429 "drop",2530 "renameFrom",2631 "table",27- "defineSchema",28- "defineApp",29- "defineSliceableApp",3032 "defineMigration",3133 "renameTableFrom",3234 "definePermissions",3335 "permissionIntrospectionColumns",3436 ] as const;3537 38+const wrappedDefineMembers = ["defineSchema", "defineApp", "defineSliceableApp"] as const;39+3640 Deno.test("schema facade exposes every curated member as the Jazz original", () => {3741 for (const member of curatedMembers) {3842 if (!(member in schema)) {
@@ -44,6 +48,40 @@ Deno.test("schema facade exposes every curated member as the Jazz original", ()
4448 }4549 });4650 51+Deno.test("the define entry points delegate to Jazz and feed the encrypted registry", () => {52+ for (const member of wrappedDefineMembers) {53+ if (!(member in schema)) {54+ throw new Error(`jazz-tools schema namespace no longer provides "${member}"`);55+ }56+ if (s[member] === (schema as Record<string, unknown>)[member]) {57+ throw new Error(58+ `facade member "${member}" lost its registration wrapper — encrypted columns ` +59+ "would no longer register",60+ );61+ }62+ }63+ clearEncryptedColumnRegistry();64+ try {65+ // Delegation is observable: defineSchema returns its definition unchanged66+ // (the pinned original's contract) while registering the sealed column.67+ const definition = {68+ diary: s.table({69+ title: s.string(),70+ body: s.encryptedText("modtest.diary.body"),71+ }),72+ };73+ const defined: unknown = s.defineSchema(definition as never);74+ if (defined !== definition) {75+ throw new Error("defineSchema no longer returns the definition unchanged");76+ }77+ if (!isEncryptedColumn("diary", "body")) {78+ throw new Error("defineSchema did not register the encrypted column");79+ }80+ } finally {81+ clearEncryptedColumnRegistry();82+ }83+});84+4785 // The nested-namespace members are the one deliberate exception to the4886 // re-export rule: they are lofi-owned (a naming layer over the pinned4987 // defineSliceableApp), so they must be lofi's functions, not Jazz members.
M package/schema/nested.ts +2 -0