@everystack/cli 0.4.41 → 0.4.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.41",
3
+ "version": "0.4.44",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.7"
112
+ "@everystack/model": "0.4.9"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { ModelDescriptor, Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
+ import { parenthesizeOnce } from './authz-contract.js';
25
26
 
26
27
  export interface CompileOptions {
27
28
  /** Schema the table lives in. Default: `public`. */
@@ -65,7 +66,11 @@ function claimExpr(claim: string): string {
65
66
  * uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
66
67
  * text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
67
68
  */
68
- function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
69
+ function ownerPredicate(sqlColumn: string, type: string, claim: string, expr: string | null = null): string {
70
+ // A declared `ownerExpr` is the accessor the database already has (`auth.user_id()`),
71
+ // emitted verbatim and uncast: the function returns the owner column's type, and a cast
72
+ // the deparser would not print is a DROP + CREATE on every owner policy in the schema.
73
+ if (expr) return `(${sqlColumn} = ${expr})`;
69
74
  const ce = claimExpr(claim);
70
75
  if (type === 'text') return `(${sqlColumn} = ${ce})`;
71
76
  return `(${sqlColumn} = (${ce})::${castForType(type)})`;
@@ -88,7 +93,7 @@ function softDeleteGuard(sqlColumn: string): string {
88
93
  * WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
89
94
  */
90
95
  function viaPredicate(v: ViaRef): string {
91
- const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
96
+ const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim, v.parentOwner.expr);
92
97
  return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
93
98
  }
94
99
 
@@ -102,6 +107,8 @@ interface OwnerRef {
102
107
  type: string;
103
108
  /** JWT claim holding the owner id. */
104
109
  claim: string;
110
+ /** A declared accessor for the caller's id (`auth.user_id()`), or null for the inline claim. */
111
+ expr: string | null;
105
112
  }
106
113
 
107
114
  /**
@@ -134,6 +141,7 @@ function resolveOwner(model: ModelDescriptor): OwnerRef | null {
134
141
  sqlColumn: toSnakeCase(fieldKey),
135
142
  type: model.fields[fieldKey]?.spec.type ?? 'uuid',
136
143
  claim: ownerAbility.condition.userField ?? 'sub',
144
+ expr: rawExpr(ownerAbility.condition.ownerExpr, `${model.table}: can({ ownerExpr })`),
137
145
  };
138
146
  }
139
147
 
@@ -176,9 +184,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
176
184
  };
177
185
  }
178
186
 
179
- /** The conventional soft-delete column SQL name, when the model has one. */
187
+ /**
188
+ * The soft-delete column SQL name, when the model DECLARES one.
189
+ *
190
+ * Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
191
+ * statement of visibility intent, and inferring the guard from it made the compiler author a
192
+ * security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
193
+ * automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
194
+ * refuses to guess: a model with the field and a public read must say which it means.
195
+ */
180
196
  function softDeleteColumn(model: ModelDescriptor): string | null {
181
- return 'deletedAt' in model.fields ? 'deleted_at' : null;
197
+ return model.softDelete ? 'deleted_at' : null;
182
198
  }
183
199
 
184
200
  /**
@@ -228,7 +244,28 @@ function rawPredicate(value: unknown, where: string): string | null {
228
244
  `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
229
245
  );
230
246
  }
231
- return `(${text.trim()})`;
247
+ // Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
248
+ // (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
249
+ // differ from the live one by a single layer of parens — enough for the reconciler to
250
+ // plan a DROP + CREATE that changed nothing.
251
+ return parenthesizeOnce(text.trim());
252
+ }
253
+
254
+ /**
255
+ * Unwrap a sql fragment to its text, UNparenthesized — for an expression that is not a
256
+ * predicate. `ownerExpr` is the right-hand side of `<col> = …`; parenthesizing it would
257
+ * emit `(user_id = (auth.user_id()))`, which is not what the deparser prints, and the
258
+ * whole point of declaring it is to match the live text exactly.
259
+ */
260
+ function rawExpr(value: unknown, where: string): string | null {
261
+ if (value == null) return null;
262
+ const text = (value as { sql?: unknown }).sql;
263
+ if (typeof text !== 'string' || !text.trim()) {
264
+ throw new Error(
265
+ `${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — the expression that decides who owns a row is authored, never stringly assembled.`,
266
+ );
267
+ }
268
+ return text.trim();
232
269
  }
233
270
 
234
271
  /** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
@@ -257,7 +294,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
257
294
  // parent subquery. Both drive the same owner-gated policies (select/insert/
258
295
  // update/delete_own); only the predicate text differs.
259
296
  const rowPred = owner
260
- ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
297
+ ? ownerPredicate(owner.sqlColumn, owner.type, owner.claim, owner.expr)
261
298
  : via
262
299
  ? viaPredicate(via)
263
300
  : null;
@@ -266,16 +303,52 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
266
303
  // A "row-scoped" condition is either a direct owner or a transitive via.
267
304
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
268
305
 
306
+ /**
307
+ * A read scoped to a named role — `can('read', { role })`. It compiles to its own
308
+ * `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
309
+ * public read's predicate: a rule written for one role would otherwise narrow what
310
+ * every other role can see.
311
+ */
312
+ const isRoleRead = (a: Ability): boolean =>
313
+ a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
314
+
269
315
  // Author-supplied predicates, per action. `manage` carries no predicate of its own —
270
316
  // it is the admin bypass — so only the specific verbs are consulted.
271
317
  const predFor = (action: Ability['action']): string | null => {
272
318
  for (const a of model.abilities) {
273
- if (a.action !== action) continue;
319
+ if (a.action !== action || isRoleRead(a)) continue;
274
320
  const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
275
321
  if (p) return p;
276
322
  }
277
323
  return null;
278
324
  };
325
+ /**
326
+ * The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
327
+ *
328
+ * Read separately from {@link ownerReadPred} because the two are different BRANCHES of
329
+ * the same authenticated policy. Taking both from one ordered lookup made the anon
330
+ * policy depend on the order the abilities happened to be declared in: put the owner
331
+ * read first and `anon` inherited the owner branch's guard as its whole public rule.
332
+ */
333
+ const publicReadPred = (): string | null => {
334
+ for (const a of model.abilities) {
335
+ if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
336
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
337
+ if (p) return p;
338
+ }
339
+ return null;
340
+ };
341
+
342
+ /** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
343
+ const ownerReadPred = (): string | null => {
344
+ for (const a of model.abilities) {
345
+ if (a.action !== 'read' || !rowScoped(a) || isColumnRead(a)) continue;
346
+ const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
347
+ if (p) return p;
348
+ }
349
+ return null;
350
+ };
351
+
279
352
  /** The WRITE half, when the author declared one distinct from the read half. */
280
353
  const checkFor = (action: Ability['action']): string | null => {
281
354
  for (const a of model.abilities) {
@@ -312,18 +385,37 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
312
385
  if (hasPublicRead) {
313
386
  // public read on a soft-delete table — split per role; authenticated owners
314
387
  // also see their own (soft-deleted) rows, hence the OR'd owner check.
315
- const readPred = predFor('read');
388
+ const readPred = publicReadPred();
316
389
  const anonUsing = andPredicates(sdGuard, readPred) ?? 'true';
317
390
  policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
318
391
 
319
- // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
320
- // owners also see their own deleted rows). It must never REPLACE it — an owner
321
- // condition on a WRITE ability used to narrow the authenticated SELECT to
322
- // own-rows-only, so anon saw the whole table and a signed-in user lost it.
323
- // Without a soft-delete guard, `true OR owner` is just `true`.
324
- const authedUsing = sdGuard && rowPred
325
- ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
326
- : anonUsing;
392
+ let authedUsing: string;
393
+ if (hasOwnerRead && rowPred && anonUsing !== 'true') {
394
+ // PUBLIC **OR** MINE. Two read abilities on one model a public read and an
395
+ // owner read are two branches of one authenticated policy, not a contest the
396
+ // public one wins. They used to be exactly that contest: `hasPublicRead` took
397
+ // the branch and the owner read was computed and discarded, so a signed-in
398
+ // author could not see their own non-public rows. On a real brownfield schema
399
+ // that hid ~13k legacy `pending` rows from the people who wrote them.
400
+ //
401
+ // The owner branch escapes the public predicate (that is the whole point) and
402
+ // carries its OWN guard — `can('read', { owner: 'userId', sql: … })` — so the
403
+ // author says what "mine" means without loosening what "public" means.
404
+ const ownBranch = andPredicates(rowPred, ownerReadPred())!;
405
+ authedUsing = `(${anonUsing} OR ${ownBranch})`;
406
+ } else {
407
+ // The owner predicate may only ever WIDEN a public read (the soft-delete OR:
408
+ // owners also see their own deleted rows). It must never REPLACE it — an owner
409
+ // condition on a WRITE ability used to narrow the authenticated SELECT to
410
+ // own-rows-only, so anon saw the whole table and a signed-in user lost it.
411
+ // Without a soft-delete guard, `true OR owner` is just `true` — and an
412
+ // unfiltered public read already contains every row an owner could add, which
413
+ // is why `anonUsing === 'true'` skips the disjunction rather than emitting a
414
+ // branch that can never change the answer.
415
+ authedUsing = sdGuard && rowPred
416
+ ? andPredicates(`(${sdGuard} OR ${rowPred})`, readPred)!
417
+ : anonUsing;
418
+ }
327
419
  policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
328
420
  } else if (hasOwnerRead && rowPred) {
329
421
  // owner-scoped read — no anon visibility; you see only the rows you own
@@ -336,6 +428,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
336
428
  policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
337
429
  }
338
430
 
431
+ // Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
432
+ // none of the branches above: the public read requires NO role, and the owner/column reads
433
+ // require a row-scoping condition. Before this they compiled to a SELECT grant and no
434
+ // policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
435
+ // the model read as though it could see its own. The declared predicate was computed and
436
+ // discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
437
+ // that evaporates is an authorization hole wearing the costume of a working rule.
438
+ for (const a of abilities.filter(isRoleRead)) {
439
+ const role = a.condition.role!;
440
+ const name = `${t}_select_${role}`;
441
+ const where = `${table}: can('read', { role: '${role}' })`;
442
+ if (policies.some((p) => p.name === name)) {
443
+ throw new Error(
444
+ `${where} collides with the ${name} policy already compiled from this model's public read. `
445
+ + `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
446
+ );
447
+ }
448
+ policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
449
+ }
450
+
339
451
  // owner-gated writes. INSERT checks ownership (you can only create rows you own);
340
452
  // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
341
453
  //
@@ -82,6 +82,51 @@ export interface PolicyContract {
82
82
  check: string | null;
83
83
  }
84
84
 
85
+ /**
86
+ * The WITH CHECK expression PostgreSQL will actually enforce.
87
+ *
88
+ * For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
89
+ * `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
90
+ * (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
91
+ * WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
92
+ * different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
93
+ * defeats a zero-statement bar even when the SQL is identical.
94
+ *
95
+ * `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
96
+ * and no defaulting applies.
97
+ */
98
+ export function effectivePolicyCheck(p: PolicyContract): string | null {
99
+ if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
100
+ return p.check;
101
+ }
102
+
103
+ /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
104
+ export function isWrappedExpression(s: string): boolean {
105
+ if (!s.startsWith('(') || !s.endsWith(')')) return false;
106
+ let depth = 0;
107
+ for (let i = 0; i < s.length; i++) {
108
+ if (s[i] === '(') depth++;
109
+ else if (s[i] === ')') {
110
+ depth--;
111
+ if (depth === 0 && i < s.length - 1) return false;
112
+ }
113
+ }
114
+ return depth === 0;
115
+ }
116
+
117
+ /**
118
+ * A predicate parenthesized exactly once — the normal form both producers must agree on.
119
+ *
120
+ * `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
121
+ * one into a model verbatim. Wrapping it again produced a compiled predicate identical to
122
+ * live but for one layer of parens, which the equality check called a difference and turned
123
+ * into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
124
+ * comparison did not, so both go through this.
125
+ */
126
+ export function parenthesizeOnce(expr: string): string {
127
+ return isWrappedExpression(expr) ? expr : `(${expr})`;
128
+ }
129
+
85
130
  /**
86
131
  * One function, enumerated. The cross-table authorization that RLS does not hold lives
87
132
  * in SECURITY DEFINER functions as imperative gates; the contract records that they
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
581
626
  if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
582
627
  if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
583
628
  if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
584
- if ((dp.check ?? '') !== (lp.check ?? '')) changes.push(`WITH CHECK changed`);
629
+ // Compared through the server's own defaulting rule, so this agrees with
630
+ // emitReconcileSql — otherwise db:check reports drift the plan does not carry.
631
+ if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
585
632
  if (changes.length) {
586
633
  out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
587
634
  }
@@ -65,11 +65,120 @@ const KNOWN_ROLES = new Set(['anon', 'authenticated', 'admin']);
65
65
  const OWNER_RE =
66
66
  /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
67
67
 
68
- /** Does this predicate say "the row is mine"? Returns the column and the claim if so. */
69
- export function parseOwnerPredicate(pred: string | null): { column: string; claim: string } | null {
68
+ /**
69
+ * The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
70
+ *
71
+ * A brownfield schema almost always has one — a STABLE SECURITY DEFINER function wrapping
72
+ * the same claim read — and every owner policy in it is written against that function, not
73
+ * against the claim inline. Rendering those as the inline claim would be a semantically
74
+ * similar but TEXTUALLY different predicate, and predicates are diffed as text: it would
75
+ * plan a DROP + CREATE on every owner policy in the schema.
76
+ *
77
+ * Deliberately narrow: the right side must be a zero-argument function call. That is what
78
+ * an "who am I" accessor looks like, and it is what a column-to-column comparison
79
+ * (`(user_id = reviewer_id)`) is not — so the matcher cannot mistake a join predicate for
80
+ * a statement of ownership.
81
+ */
82
+ const OWNER_FN_RE = /^\(?([a-z_][a-z0-9_]*)\s*=\s*((?:[a-z_][a-z0-9_]*\.)?[a-z_][a-z0-9_]*\(\))\)?$/i;
83
+
84
+ /**
85
+ * Does this predicate say "the row is mine"? Returns the column, the claim, and the
86
+ * accessor expression when the schema uses one (null = the claim read inline).
87
+ */
88
+ export function parseOwnerPredicate(
89
+ pred: string | null,
90
+ ): { column: string; claim: string; expr: string | null } | null {
70
91
  if (!pred) return null;
71
- const m = OWNER_RE.exec(pred.trim());
72
- return m ? { column: m[1], claim: m[2] } : null;
92
+ const t = pred.trim();
93
+ const m = OWNER_RE.exec(t);
94
+ if (m) return { column: m[1], claim: m[2], expr: null };
95
+ const f = OWNER_FN_RE.exec(t);
96
+ return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
97
+ }
98
+
99
+ /**
100
+ * Split a predicate on a top-level operator, respecting parens and string literals.
101
+ *
102
+ * Needed because the shapes worth recognising are compositional — `(public) OR (mine AND
103
+ * guard)` — and a naive `.split(' OR ')` cuts inside a nested disjunction, which is how a
104
+ * template matcher silently mis-reads `((state <> 'deleted') AND ((user_id = …) OR (…)))`
105
+ * as a top-level OR it is not.
106
+ */
107
+ function splitTopLevel(pred: string, op: 'OR' | 'AND'): string[] {
108
+ const needle = ` ${op} `;
109
+ const parts: string[] = [];
110
+ let depth = 0;
111
+ let quoted = false;
112
+ let start = 0;
113
+ // Strip one enclosing paren pair so the operator we want is genuinely at depth 0.
114
+ const s = unwrapOnce(pred.trim());
115
+ for (let i = 0; i < s.length; i++) {
116
+ const c = s[i];
117
+ if (c === "'") { quoted = !quoted; continue; }
118
+ if (quoted) continue;
119
+ if (c === '(') depth++;
120
+ else if (c === ')') depth--;
121
+ else if (depth === 0 && s.startsWith(needle, i)) {
122
+ parts.push(s.slice(start, i));
123
+ i += needle.length - 1;
124
+ start = i + 1;
125
+ }
126
+ }
127
+ parts.push(s.slice(start));
128
+ return parts.map((p) => p.trim()).filter(Boolean);
129
+ }
130
+
131
+ /** Drop one enclosing paren pair, but only when it spans the WHOLE expression. */
132
+ function unwrapOnce(s: string): string {
133
+ if (!s.startsWith('(') || !s.endsWith(')')) return s;
134
+ let depth = 0;
135
+ let quoted = false;
136
+ for (let i = 0; i < s.length; i++) {
137
+ const c = s[i];
138
+ if (c === "'") { quoted = !quoted; continue; }
139
+ if (quoted) continue;
140
+ if (c === '(') depth++;
141
+ else if (c === ')') {
142
+ depth--;
143
+ if (depth === 0 && i < s.length - 1) return s; // the pair closed early — not enclosing
144
+ }
145
+ }
146
+ return depth === 0 ? s.slice(1, -1).trim() : s;
147
+ }
148
+
149
+ /**
150
+ * Decompose an authenticated read of the form `(public) OR (mine [AND guard])`, given the
151
+ * public predicate the anon policy already states.
152
+ *
153
+ * Keyed on the ANON PREDICATE APPEARING VERBATIM as one of two top-level disjuncts, and an
154
+ * owner predicate appearing in the other — not on a shape template. The alternative,
155
+ * matching a `X OR (Y AND Z)` template, both over- and under-fires: it claims predicates it
156
+ * has not proven are a public branch, and it misses the schema whose OR is nested inside an
157
+ * AND. Anything that does not decompose EXACTLY returns null, and the caller says so.
158
+ */
159
+ export function parsePublicOrOwn(
160
+ authedPred: string | null,
161
+ anonPred: string | null,
162
+ ): { owner: { column: string; claim: string; expr: string | null }; ownerSql: string | null } | null {
163
+ if (!authedPred || !anonPred) return null;
164
+ const branches = splitTopLevel(authedPred, 'OR');
165
+ if (branches.length !== 2) return null;
166
+
167
+ const anonNorm = unwrapOnce(anonPred.trim());
168
+ const publicIdx = branches.findIndex((b) => unwrapOnce(b) === anonNorm);
169
+ if (publicIdx === -1) return null;
170
+
171
+ const ownBranch = branches[1 - publicIdx];
172
+ const conjuncts = splitTopLevel(ownBranch, 'AND');
173
+ const ownerIdx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
174
+ if (ownerIdx === -1) return null;
175
+
176
+ const owner = parseOwnerPredicate(conjuncts[ownerIdx])!;
177
+ const rest = conjuncts.filter((_, i) => i !== ownerIdx);
178
+ // More than one leftover conjunct would have to be re-ANDed, and the text the compiler
179
+ // then emits is not guaranteed to be the text that was read. Refuse rather than guess.
180
+ if (rest.length > 1) return null;
181
+ return { owner, ownerSql: rest[0] ?? null };
73
182
  }
74
183
 
75
184
  /** Is a privilege EFFECTIVE for a role — i.e. actually granted, not merely policed? */
@@ -135,15 +244,48 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
135
244
  if (anonRead) {
136
245
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
137
246
  // carry that predicate — a public read with a filter is still a public read.
138
- const pol = policiesFor(contract, 'anon', 'SELECT')[0];
139
- const frag = sqlFragment(pol?.using ?? null);
247
+ const anonPol = policiesFor(contract, 'anon', 'SELECT')[0];
248
+ const anonPred = anonPol?.using ?? null;
249
+ const frag = sqlFragment(anonPred);
140
250
  abilities.push(frag ? `can('read', { sql: ${frag} })` : `can('read')`);
251
+
252
+ // The authenticated SELECT is a SEPARATE policy and routinely says MORE than the anon
253
+ // one — `(public) OR (mine)`, so an author sees their own not-yet-public rows. Reading
254
+ // only the anon policy dropped that branch and rendered a model that meant strictly
255
+ // less than the database did, with nothing said. On a real schema it hid ~13,184
256
+ // legacy rows from the people who wrote them.
257
+ const authedPol = authedRead
258
+ ? policiesFor(contract, 'authenticated', 'SELECT').find((p) => p.name !== anonPol?.name)
259
+ : undefined;
260
+ const authedPred = authedPol?.using ?? null;
261
+ if (authedPol && (authedPred ?? 'true') !== (anonPred ?? 'true')) {
262
+ const own = parsePublicOrOwn(authedPred, anonPred);
263
+ if (own) {
264
+ const parts = [`owner: '${own.owner.column}'`];
265
+ if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
266
+ if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
267
+ const guard = sqlFragment(own.ownerSql);
268
+ if (guard) parts.push(`sql: ${guard}`);
269
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
270
+ } else {
271
+ // P6: it must SAY SO. An unexpressible predicate becomes a comment naming the
272
+ // policy and its text, never a model that quietly means less.
273
+ notes.push(
274
+ `policy "${authedPol.name}" (SELECT) grants authenticated MORE than the anon read,`,
275
+ );
276
+ notes.push(` and its shape is not expressible as abilities. Live USING:`);
277
+ notes.push(` ${authedPred}`);
278
+ notes.push(` Declare it by hand, or leave the table adopted. NOT rendered above.`);
279
+ }
280
+ }
141
281
  } else if (authedRead) {
142
282
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
143
283
  const owner = parseOwnerPredicate(pol?.using ?? null);
144
284
  if (owner) {
145
- const claim = owner.claim === 'sub' ? '' : `, userField: '${owner.claim}'`;
146
- abilities.push(`can('read', { owner: '${owner.column}'${claim} })`);
285
+ const parts = [`owner: '${owner.column}'`];
286
+ if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
287
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
288
+ abilities.push(`can('read', { ${parts.join(', ')} })`);
147
289
  } else {
148
290
  const frag = sqlFragment(pol?.using ?? null);
149
291
  abilities.push(frag ? `can('read', { role: 'authenticated', sql: ${frag} })` : `can('read', { role: 'authenticated' })`);
@@ -165,6 +307,7 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
165
307
  if (owner) {
166
308
  parts.push(`owner: '${owner.column}'`);
167
309
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
310
+ if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
168
311
  }
169
312
  // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
170
313
  const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
@@ -200,7 +343,11 @@ export function deriveAbilities(contract: TableContract): DerivedAbilities {
200
343
  /** Render one table's derived stanza as the lines that sit inside a model literal. */
201
344
  export function renderDerivedAbilities(d: DerivedAbilities): string {
202
345
  const out: string[] = [];
203
- for (const n of d.notes) out.push(` // ${n}`);
346
+ // Split on newlines: a note may carry a live predicate verbatim, and `pg_get_expr`
347
+ // deparses a subquery across several lines. One `//` for the first line left the rest
348
+ // as bare SQL in the middle of a TypeScript object — a generated file that would not
349
+ // parse. Commenting per line here means no note, present or future, can do that.
350
+ for (const n of d.notes) for (const line of String(n).split('\n')) out.push(` // ${line}`);
204
351
  if (d.abilities.length) {
205
352
  out.push(` abilities: [${d.abilities.join(', ')}],`);
206
353
  } else {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
+ import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
19
20
  import { quoteQualified } from './pg-ident.js';
20
21
 
21
22
  /**
@@ -116,24 +117,8 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
116
117
  return lines;
117
118
  }
118
119
 
119
- /** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
120
- function isWrapped(s: string): boolean {
121
- if (!s.startsWith('(') || !s.endsWith(')')) return false;
122
- let depth = 0;
123
- for (let i = 0; i < s.length; i++) {
124
- if (s[i] === '(') depth++;
125
- else if (s[i] === ')') {
126
- depth--;
127
- if (depth === 0 && i < s.length - 1) return false;
128
- }
129
- }
130
- return depth === 0;
131
- }
132
-
133
120
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
134
- function clause(expr: string): string {
135
- return isWrapped(expr) ? expr : `(${expr})`;
136
- }
121
+ const clause = parenthesizeOnce;
137
122
 
138
123
  /** A CREATE POLICY statement from a policy descriptor. */
139
124
  function policyCreateSql(table: string, p: PolicyContract): string {
@@ -150,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
150
135
  return `DROP POLICY IF EXISTS ${name} ON ${table};`;
151
136
  }
152
137
 
153
- /** Two policies are the same authorization when every diffed field matches. */
138
+ /**
139
+ * Two policies are the same authorization when every diffed field matches. The check is
140
+ * compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule —
141
+ * a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
142
+ * authorize identically, and reconciling them would emit a DROP + CREATE that changes
143
+ * nothing.
144
+ */
154
145
  function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
155
146
  return a.command === b.command
156
147
  && a.permissive === b.permissive
157
148
  && a.roles.join(',') === b.roles.join(',')
158
149
  && (a.using ?? '') === (b.using ?? '')
159
- && (a.check ?? '') === (b.check ?? '');
150
+ && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
160
151
  }
161
152
 
162
153
  function tableMap(c: AuthzContract): Map<string, TableContract> {