@everystack/cli 0.4.80 → 0.4.82

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.82] - 2026-09-08
8
+
9
+ ### Fixed
10
+
11
+ - four compiler-hardening throws close the A22/F-A10/F-A12 tail
12
+ - column-path check disclosure parity closes the F1 gap
13
+
14
+ ## [0.4.81] - 2026-09-08
15
+
16
+ ### Added
17
+
18
+ - every declared write compiles a policy; check narrows, never replaces
19
+
20
+ ### Fixed
21
+
22
+ - db:pull recovers owner: from the normalized predicate and round-trips
23
+
7
24
  ## [0.4.80] - 2026-09-07
8
25
 
9
26
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.80",
3
+ "version": "0.4.82",
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>",
Binary file
@@ -92,14 +92,20 @@ function deriveExtraPrivileges(contract: TableContract): Record<string, string[]
92
92
  }
93
93
 
94
94
  /**
95
- * The deparsed owner predicate the compiler produces, in both its casts:
96
- * (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
95
+ * The deparsed owner predicate, in BOTH wire spellings — the `::text` casts are optional:
96
+ * normalized (what both producers emit since a616a1f3, 2026-08-24, and what a live stage
97
+ * returns through `predicate()`'s cross-version normalizer):
98
+ * (col = (((current_setting('request.jwt.claims', true))::jsonb ->> 'sub'))::uuid)
99
+ * raw old-major deparse (a dump captured before the normalizer, or an older stage):
100
+ * (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
97
101
  * Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
98
- * instead of an opaque `sql:` fragment. Anything unrecognised stays verbatim rather than
99
- * being guessed at.
102
+ * instead of an opaque `sql:` fragment. Before the casts were made optional the matcher
103
+ * expected only the raw spelling, so a normalized live predicate — every one, today — fell
104
+ * through to `sql:` and lost the `owner:` on round-trip. Anything unrecognised still stays
105
+ * verbatim rather than being guessed at.
100
106
  */
101
107
  const OWNER_RE =
102
- /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
108
+ /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)*(?:::[a-z]+)?\)?$/i;
103
109
 
104
110
  /**
105
111
  * The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
@@ -132,6 +138,33 @@ export function parseOwnerPredicate(
132
138
  return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
133
139
  }
134
140
 
141
+ /**
142
+ * Decompose a WRITE predicate into its owner conjunct and the remaining guard.
143
+ *
144
+ * The compiler builds an owner write as `rowScope AND where` (USING) and `rowScope AND (check ??
145
+ * where)` (WITH CHECK). To round-trip, the deriver reverses that: the owner term may be the whole
146
+ * predicate (`owner = claim`) or one top-level AND conjunct (`owner = claim AND state = 'draft'`),
147
+ * and everything else is the guard the compiler will AND back in beside the owner. Returns the
148
+ * owner parse (null when there is none) and the guard text (null when the owner is the whole
149
+ * predicate). Nothing is guessed: a predicate with no recognisable owner conjunct returns
150
+ * `{ owner: null, guard: <the whole predicate> }`, which the caller carries verbatim as `sql:`.
151
+ */
152
+ function splitOwnerPredicate(
153
+ pred: string | null,
154
+ ): { owner: ReturnType<typeof parseOwnerPredicate>; guard: string | null } {
155
+ if (!pred) return { owner: null, guard: null };
156
+ const direct = parseOwnerPredicate(pred);
157
+ if (direct) return { owner: direct, guard: null };
158
+ const conjuncts = splitTopLevel(pred, 'AND');
159
+ if (conjuncts.length < 2) return { owner: null, guard: pred };
160
+ const idx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
161
+ if (idx === -1) return { owner: null, guard: pred };
162
+ const owner = parseOwnerPredicate(conjuncts[idx]);
163
+ const rest = conjuncts.filter((_, i) => i !== idx);
164
+ const guard = rest.length === 0 ? null : rest.length === 1 ? rest[0] : `(${rest.join(' AND ')})`;
165
+ return { owner, guard };
166
+ }
167
+
135
168
  /**
136
169
  * Split a predicate on a top-level operator, respecting parens and string literals.
137
170
  *
@@ -331,15 +364,24 @@ function renderColumnAbility(
331
364
  grantee: string,
332
365
  columns: readonly string[],
333
366
  policy: PolicyContract,
367
+ notes: string[],
334
368
  ): string | null {
335
369
  const using = policy.using ?? null;
336
370
  const owner = parseOwnerPredicate(using);
337
371
  const parts: string[] = [];
338
372
  const role = policyRoleForGrantee(grantee);
339
373
 
340
- // Owner-only uses the model's authenticated default. Every other grantee must be named so
341
- // the compiler emits the policy TO the live role, rather than a role-less public ability.
342
- if (!owner || role !== 'authenticated') parts.push(`role: '${role}'`);
374
+ // A distinct WITH CHECK on a column UPDATE must ride as `check:`, and the compiler REFUSES a
375
+ // role-less column ability that carries any row predicate (F-A12: a column ability has nowhere
376
+ // row-level to put one). So a differing check forces the NAMED-role spelling — `role:
377
+ // 'authenticated'` — which routes to the compiler's named-role column path: that path ANDs the
378
+ // owner back into BOTH halves and honors the check, where the role-less shape would throw.
379
+ const distinctCheck = action === 'update' && policy.check != null && policy.check !== using;
380
+
381
+ // Owner-only uses the model's authenticated default. Every other grantee must be named so the
382
+ // compiler emits the policy TO the live role, not a role-less public ability — and a differing
383
+ // check names it too, to escape the F-A12 refusal above.
384
+ if (!owner || role !== 'authenticated' || distinctCheck) parts.push(`role: '${role}'`);
343
385
  if (owner) {
344
386
  parts.push(`owner: '${modelFieldKey(owner.column)}'`);
345
387
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
@@ -349,10 +391,26 @@ function renderColumnAbility(
349
391
  if (fragment) parts.push(`sql: ${fragment}`);
350
392
  }
351
393
 
352
- if (action === 'update' && policy.check && policy.check !== using) {
353
- const fragment = sqlFragment(policy.check);
394
+ if (distinctCheck) {
395
+ // Emit only the guard BEYOND the owner gate. The live WITH CHECK restates the owner term
396
+ // (the compiler's named-role column path ANDs it back in), so carrying the whole predicate
397
+ // would double the owner on recompile and churn the plan. Strip it exactly as the row-level
398
+ // write path does; a check with no owner conjunct rides whole (guard === the check).
399
+ const split = owner ? splitOwnerPredicate(policy.check) : null;
400
+ const guard = split ? split.guard : policy.check;
401
+ const fragment = sqlFragment(guard);
354
402
  if (!fragment) return null;
355
403
  parts.push(`check: ${fragment}`);
404
+ // Disclosure parity with the row-level write path. When USING is a PURE owner predicate but
405
+ // the live WITH CHECK does NOT restate the owner, the compiler ANDs the owner back into the
406
+ // rendered check — TIGHTENING it (the F-A11 "wider write half" the model cannot express). Say
407
+ // so, rather than let an unexplained conjunct surface in the next db:plan, which an operator
408
+ // might "fix" back toward the wider live shape by hand — the exact spelling the compiler refuses.
409
+ if (split && split.owner == null) {
410
+ notes.push(`policy "${policy.name}" (UPDATE) has a WITH CHECK that does not restate the owner gate in its USING —`);
411
+ notes.push(` it is wider than a column owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
412
+ notes.push(` ${policy.check}`);
413
+ }
356
414
  }
357
415
  parts.push(`columns: [${columns.map((column) => `'${modelFieldKey(column)}'`).join(', ')}]`);
358
416
  return `can('${action}', { ${parts.join(', ')} })`;
@@ -562,7 +620,7 @@ export function deriveAbilities(
562
620
  }
563
621
 
564
622
  if (diverges && own) {
565
- const parts = [`owner: '${own.owner.column}'`];
623
+ const parts = [`owner: '${modelFieldKey(own.owner.column)}'`];
566
624
  if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
567
625
  if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
568
626
  const guard = sqlFragment(own.ownerSql);
@@ -597,7 +655,7 @@ export function deriveAbilities(
597
655
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
598
656
  const owner = parseOwnerPredicate(pol?.using ?? null);
599
657
  if (owner) {
600
- const parts = [`owner: '${owner.column}'`];
658
+ const parts = [`owner: '${modelFieldKey(owner.column)}'`];
601
659
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
602
660
  if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
603
661
  abilities.push(`can('read', { ${parts.join(', ')} })`);
@@ -616,22 +674,58 @@ export function deriveAbilities(
616
674
  ?? policiesFor(contract, 'authenticated', command)[0];
617
675
  const using = pol?.using ?? null;
618
676
  const check = pol?.check ?? null;
619
- const owner = parseOwnerPredicate(using) ?? parseOwnerPredicate(check);
677
+ // Decompose both halves into (owner, guard). INSERT carries the owner in WITH CHECK (its
678
+ // USING is null); UPDATE/DELETE carry it in USING. The owner is recovered from whichever
679
+ // half has it.
680
+ const u = splitOwnerPredicate(using);
681
+ const c = splitOwnerPredicate(check);
682
+ const owner = u.owner ?? c.owner;
620
683
 
621
684
  const parts: string[] = [];
622
685
  if (owner) {
623
- parts.push(`owner: '${owner.column}'`);
686
+ parts.push(`owner: '${modelFieldKey(owner.column)}'`);
624
687
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
625
688
  if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
626
- }
627
- // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
628
- const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
629
- if (usingFrag) parts.push(`sql: ${usingFrag}`);
630
- // The WRITE half, only when it genuinely differs. Postgres defaults it to USING, and
631
- // emitting it unconditionally would be noise that also defeats the round-trip diff.
632
- if (check && check !== using) {
633
- const checkFrag = sqlFragment(check);
634
- if (checkFrag) parts.push(`check: ${checkFrag}`);
689
+ // The USING guard beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
690
+ const usingGuard = sqlFragment(u.guard);
691
+ if (usingGuard) parts.push(`sql: ${usingGuard}`);
692
+ // The WRITE half rides as `check` ONLY when its guard differs from the USING guard. The
693
+ // compiler defaults WITH CHECK to (rowScope AND where), so emitting an equal guard would
694
+ // double the owner term on recompile — the round-trip regression this closes.
695
+ // WITH CHECK handling. `check == null` means the live policy has NO explicit WITH CHECK
696
+ // (Postgres defaults it to USING at runtime) — emit no `check:` and let the compiler
697
+ // default it the same way. Only an EXPLICIT check that differs from the USING guard needs a
698
+ // `check:`, and only in the shapes below.
699
+ if (command !== 'DELETE' && check != null && c.owner == null) {
700
+ // The live WITH CHECK does not even restate the owner gate that USING carries — it is
701
+ // WIDER on the owner axis (the extreme is `WITH CHECK (true)`: write a row you do not
702
+ // own). The model has no spelling for that, and every `check:` the compiler emits ANDs
703
+ // the owner back in, so the derived ability necessarily TIGHTENS live. Disclose it rather
704
+ // than round-trip the loss silently; the residual db:plan statement is the real diff.
705
+ notes.push(`policy "${pol?.name}" (${command}) has a WITH CHECK that does not restate the owner gate in its USING —`);
706
+ notes.push(` it is wider than any owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
707
+ notes.push(` ${check}`);
708
+ } else if (command !== 'DELETE' && check != null && c.owner != null && c.guard == null && u.guard != null) {
709
+ // Explicit owner-only WITH CHECK while USING carries a guard — the write half is
710
+ // deliberately WIDER than the read half (the moderation shape this file's header names:
711
+ // "your own draft rows" to target, "your own row" to write into). Left implicit, the
712
+ // compiler would default WITH CHECK to (owner AND where) and TIGHTEN it, so the pull would
713
+ // misreport live. `check: sql`true`` forces WITH CHECK back to the owner gate alone.
714
+ parts.push('check: sql`true`');
715
+ } else if (c.guard != null && (c.guard ?? '') !== (u.guard ?? '')) {
716
+ // An explicit WITH CHECK carrying a DIFFERENT guard than USING (owner ∧ a distinct write
717
+ // guard). Rides as `check:`; the compiler ANDs the owner back in.
718
+ const checkFrag = sqlFragment(c.guard);
719
+ if (checkFrag) parts.push(`check: ${checkFrag}`);
720
+ }
721
+ } else {
722
+ // No owner recovered — carry the predicates verbatim, USING and (differing) WITH CHECK.
723
+ const usingFrag = sqlFragment(using);
724
+ if (usingFrag) parts.push(`sql: ${usingFrag}`);
725
+ if (check && check !== using) {
726
+ const checkFrag = sqlFragment(check);
727
+ if (checkFrag) parts.push(`check: ${checkFrag}`);
728
+ }
635
729
  }
636
730
 
637
731
  if (!parts.length && !pol) {
@@ -670,7 +764,7 @@ export function deriveAbilities(
670
764
  if (!cols.length) continue;
671
765
  const action = priv === 'SELECT' ? 'read' : priv === 'UPDATE' ? 'update' : null;
672
766
  const policy = action ? columnPolicyFor(contract, grantee, priv) : null;
673
- const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy) : null;
767
+ const ability = action && policy ? renderColumnAbility(action, grantee, cols, policy, notes) : null;
674
768
  if (ability) {
675
769
  abilities.push(ability);
676
770
  renderedColumnRoles.add(grantee);