@everystack/cli 0.4.99 → 0.4.102

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +3 -3
  3. package/src/cli/apply-execute.ts +15 -1
  4. package/src/cli/authz-baseline.ts +8 -4
  5. package/src/cli/authz-compile.ts +61 -7
  6. package/src/cli/authz-contract.ts +23 -14
  7. package/src/cli/authz-derive.ts +282 -30
  8. package/src/cli/authz-reconcile.ts +6 -4
  9. package/src/cli/catalog-rename.ts +115 -0
  10. package/src/cli/commands/db-check.ts +40 -0
  11. package/src/cli/commands/db-generate.ts +10 -9
  12. package/src/cli/commands/db-plan.ts +30 -13
  13. package/src/cli/commands/db-pull.ts +153 -63
  14. package/src/cli/commands/db-reconcile.ts +22 -3
  15. package/src/cli/commands/db-sync.ts +6 -5
  16. package/src/cli/commands/db.ts +91 -8
  17. package/src/cli/commands/update.ts +3 -2
  18. package/src/cli/db-build.ts +1 -0
  19. package/src/cli/declared-derived.ts +3 -1
  20. package/src/cli/declared-diff.ts +4 -1
  21. package/src/cli/derived-apply.ts +116 -10
  22. package/src/cli/derived-grants.ts +4 -2
  23. package/src/cli/derived-introspect.ts +23 -3
  24. package/src/cli/derived-plan.ts +37 -1
  25. package/src/cli/derived-render.ts +33 -34
  26. package/src/cli/edge-plan.ts +57 -9
  27. package/src/cli/generated-artifacts.ts +22 -0
  28. package/src/cli/generated-names.ts +108 -0
  29. package/src/cli/host-url.ts +48 -27
  30. package/src/cli/index.ts +5 -5
  31. package/src/cli/lifecycle-canon.ts +37 -0
  32. package/src/cli/lifecycle-derived.ts +721 -0
  33. package/src/cli/lifecycle-pull.ts +177 -0
  34. package/src/cli/lifecycle-verify.ts +232 -0
  35. package/src/cli/migration-compile.ts +8 -3
  36. package/src/cli/migration-generate.ts +23 -3
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +243 -77
  39. package/src/cli/plain-output.ts +198 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +57 -18
  42. package/src/cli/schema-diff.ts +184 -41
  43. package/src/cli/schema-fingerprint.ts +39 -6
  44. package/src/cli/schema-introspect.ts +66 -2
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ 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.102] - 2026-10-23
8
+
9
+ ### Added
10
+
11
+ - db:doctor reports lifecycle rows past the purge window
12
+ - generated lifecycle functions carry manifests
13
+ - db:pull verifies generated lifecycle objects
14
+
15
+ ### Fixed
16
+
17
+ - restore uses set-based updates, not a subtransaction per row
18
+ - drop a replaced primary key before altering its columns
19
+ - match quoted trigger functions by parsed identifiers
20
+ - db:pull renders the owner read pair as owner
21
+ - the verifier leaves an author's own restore overload alone
22
+ - allow author after-delete lifecycle triggers
23
+ - detect constraint lifecycle trigger replacements
24
+ - the verifier compares the declared delete rules with the live policies
25
+ - an invalid --purge-after fails cleanly
26
+ - check lifecycle restrict edges once, after the whole cascade
27
+ - reconcile drops a removed trigger before the function it executes
28
+ - a DELETE issued from inside a trigger on a lifecycle table is a soft delete
29
+ - pull-verify names the older CLI's restrict pair as stale and points to db:apply
30
+ - pull-verify drops only a whole pg_catalog qualifier from a trigger WHEN
31
+
32
+ ### Documentation
33
+
34
+ - state the lifecycle restrict contract, multi-row order and the lifecycle-column rule
35
+ - consumer triggers under a stamp or purge: qualified names, soft deletes, and the db:apply remedy for an older CLI's restrict pair
36
+ - an older CLI's restrict function may report a missing manifest instead of stale; both say db:apply
37
+
38
+ ## [0.4.101] - 2026-10-22
39
+
40
+ ### Added
41
+
42
+ - db:plan lists the functions, views and triggers db:apply will change
43
+ - update names the .sst files it read when it finds no HOST_URL
44
+ - external FK target, .references({ schema, table, column })
45
+ - F7a relation options; hasMany dependent sets the child FK ON DELETE
46
+ - relation display options and hasOne (F7b)
47
+ - db:generate writes the model map; db:check fails when it is stale (F8)
48
+ - the model map carries softDelete; the admin reads it instead of the preset's hand-written flag (A5)
49
+ - hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
50
+ - lifecycle: true declares four generated columns (L1.1)
51
+ - lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
52
+ - lifecycle triggers turn DELETE into a soft delete (L1.2b)
53
+ - lifecycle restore, for whoever may delete the row (L1.3)
54
+ - db:pull folds a lifecycle table back to lifecycle: true (L1.4)
55
+ - a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
56
+ - <t>_restore brings back the row's whole batch subtree on the root's authority (L3, P1.3)
57
+ - Restore on a lifecycle resource restores its batch and previews it first (P1.5)
58
+ - db:provision creates the reserved lifecycle role (P2.7)
59
+ - a NOT NULL column has no clear control (N6)
60
+
61
+ ### Fixed
62
+
63
+ - a disabled derived trigger is drift
64
+ - import a non-public FK target by its qualified name
65
+ - a plan that removes a function, view or trigger is not "Safe to apply"
66
+ - key the pulled set on qualified names
67
+ - db:pull --govern-roles round-trips custom-role policies and column grants (assoc F4)
68
+ - db:pull refuses a no-USING column policy and escapes live predicates (assoc F4 review)
69
+ - escape live text in db:pull's sql templates
70
+ - escape the defineSql fallback's DROP FUNCTION template
71
+ - db:pull refuses live names with no safe generated identifier or file name
72
+ - db:pull escapes live text in // comments and walls every write inside its directory
73
+ - a grant to a role named __proto__ is visible to db:plan, db:pull and the fingerprint
74
+ - db:pull never renders a policy that admits no row as an ability
75
+ - F3e review minors: catalog FK target, one target slot, derived refusal
76
+ - F3e re-review: fail closed on a changed FK target; unambiguous comment
77
+ - F7a review: derive dependent ON DELETE per set, never write a field spec
78
+ - F7b review: mark display relations in drizzle, withhold only where/limit
79
+ - F8 review: private SQL spellings, key collisions, T2 mock proof
80
+ - run state DDL under the canonical search_path; read non-public enums as qualified
81
+ - retype a column onto an enum through text, dropping the old default first
82
+ - the map publishes the soft-delete field key, not a boolean (A5 review)
83
+ - owned trigger functions deploy as a non-superuser; the lifecycle transition runs only on its own table (L1.2c)
84
+ - the lifecycle pull fold keeps an author's restore overload and governs the reserved role at adoption (L1.4 review)
85
+ - the lifecycle pull fold checks the reserved role's effective privileges and always names its unfolded grants (P1.0 review)
86
+ - db:plan changes a primary key, and refuses to mint what it cannot express (P2.8)
87
+
88
+ ### Changed
89
+
90
+ - fake timers for the two polling suites; generate the default cert once
91
+
92
+ ### Documentation
93
+
94
+ - name the one runtime-vs-generated Drizzle difference a parent dependent creates
95
+ - say which fingerprint inputs moved; mark the guard-only enum diff test
96
+ - state precisely which policies admit no row and that a column grant under one is revoked
97
+
98
+ ## [0.4.100] - 2026-10-16
99
+
100
+ ### Added
101
+
102
+ - check() takes an explicit constraint name; db:pull keeps a live name
103
+
104
+ ### Fixed
105
+
106
+ - db:apply runs a rename-only plan
107
+
108
+ ### Documentation
109
+
110
+ - a check folded into .validate() takes the field check name
111
+
7
112
  ## [0.4.99] - 2026-10-14
8
113
 
9
114
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.99",
3
+ "version": "0.4.102",
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>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.21"
148
+ "@everystack/model": "0.4.23"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -192,7 +192,7 @@
192
192
  "react": "19.2.0",
193
193
  "ts-jest": "29.4.9",
194
194
  "zod": "3.25.67",
195
- "@everystack/server": "0.4.35"
195
+ "@everystack/server": "0.4.38"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -73,6 +73,7 @@ import {
73
73
  import { validationPendingReason, pendingValidationsAreSafe } from './apply-refusals';
74
74
  import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from './derived-apply';
75
75
  import { checkPlanPrecondition, planHash, defaultAclHash, type EdgePlan } from './edge-plan';
76
+ import { renameEdgeVerdict } from './catalog-rename';
76
77
  import type { DescentLedgerRow, LedgerRead } from './git-descent';
77
78
 
78
79
  /**
@@ -477,7 +478,20 @@ export async function executeApplyPlan(
477
478
  }
478
479
 
479
480
  if (live === plan.toFingerprint) {
480
- return { status: 'already-applied', liveFingerprint: live };
481
+ // The hash is blind to a catalog rename (a check is keyed by predicate, an index or key by
482
+ // content), so a plan of renames alone has from == to == live whether or not it ran. Only
483
+ // then do the names decide, read from the same snapshot as the lock. Not landed: fall
484
+ // through, and every gate below runs. Half landed, or in a shape the plan never predicted:
485
+ // stale. Everything else is today's idempotent re-apply.
486
+ const verdict = plan.fromFingerprint === plan.toFingerprint
487
+ ? renameEdgeVerdict(plan.statements, before)
488
+ : { verdict: 'not-renames' as const };
489
+ if (verdict.verdict === 'stale') {
490
+ const reason = `this plan is stale: ${verdict.reason}. Re-mint with db:plan.`;
491
+ await recordRefusal(runner, plan, live, 'rename edge', reason, options);
492
+ return { status: 'refused', liveFingerprint: live, reason };
493
+ }
494
+ if (verdict.verdict !== 'unsettled') return { status: 'already-applied', liveFingerprint: live };
481
495
  }
482
496
  const pre = checkPlanPrecondition(plan, live);
483
497
  if (!pre.ok) {
@@ -83,7 +83,9 @@ export function buildStageBaseline(
83
83
  exemptions: readonly GrantExemption[],
84
84
  meta: { observedAt: string; fingerprint: string; fpVersion?: number },
85
85
  ): BaselineStage {
86
- const grantees: Record<string, BaselineGrantee> = {};
86
+ // Null-prototype: a grantee is a live role name. On a plain object `grantees['__proto__'] ??=`
87
+ // reads Object.prototype, and the writes below would land ON Object.prototype.
88
+ const grantees: Record<string, BaselineGrantee> = Object.create(null);
87
89
  for (const e of exemptions) {
88
90
  const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
89
91
  g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
@@ -112,10 +114,10 @@ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, ent
112
114
  * itself would make the review signal worthless.
113
115
  */
114
116
  export function renderBaseline(baseline: AuthzBaseline): string {
115
- const stages: Record<string, BaselineStage> = {};
117
+ const stages: Record<string, BaselineStage> = Object.create(null);
116
118
  for (const stage of Object.keys(baseline.stages).sort()) {
117
119
  const s = baseline.stages[stage];
118
- const grantees: Record<string, BaselineGrantee> = {};
120
+ const grantees: Record<string, BaselineGrantee> = Object.create(null);
119
121
  for (const g of Object.keys(s.grantees).sort()) {
120
122
  const { privileges, tables, write } = s.grantees[g];
121
123
  grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
@@ -182,7 +184,9 @@ export function checkAgainstBaseline(
182
184
 
183
185
  for (const grantee of Object.keys(observed).sort()) {
184
186
  const now = observed[grantee];
185
- const then = recorded[grantee];
187
+ // Own keys only: the recorded baseline is JSON.parse'd, so a missing `__proto__` entry would
188
+ // read Object.prototype — truthy — and wave an unbaselined role through.
189
+ const then = Object.hasOwn(recorded, grantee) ? recorded[grantee] : undefined;
186
190
  if (!then) {
187
191
  violations.push({
188
192
  grantee,
@@ -20,7 +20,7 @@
20
20
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
21
21
  */
22
22
 
23
- import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
23
+ import { isColumnAbility, LIFECYCLE_OWNER_ROLE, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract';
25
25
  import { parenthesizeOnce, isBareCall } from './authz-contract';
26
26
  import { normalizeDeparsedExpr } from './deparse-normal';
@@ -284,17 +284,19 @@ const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' :
284
284
  * deduplicated, matching the introspected contract.
285
285
  */
286
286
  function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
287
- const out: Record<string, Record<string, Set<string>>> = {};
287
+ // Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
288
+ // '__proto__' passes the role-name guard, so a plain object would lose it.
289
+ const out: Record<string, Record<string, Set<string>>> = Object.create(null);
288
290
  for (const a of abilities) {
289
291
  if (!isColumnAbility(a)) continue;
290
292
  const role = granteeKey(a.condition.role ?? 'authenticated');
291
293
  const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
292
- const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
294
+ const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
293
295
  for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
294
296
  }
295
- const grants: Record<string, Record<string, string[]>> = {};
297
+ const grants: Record<string, Record<string, string[]>> = Object.create(null);
296
298
  for (const [role, verbs] of Object.entries(out)) {
297
- grants[role] = {};
299
+ grants[role] = Object.create(null);
298
300
  for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
299
301
  }
300
302
  return Object.keys(grants).length ? grants : undefined;
@@ -438,6 +440,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
438
440
  ? viaPredicate(via)
439
441
  : null;
440
442
  const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
443
+ // A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
444
+ // Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
445
+ // compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
446
+ // restore or purge them), exactly as it does under softDelete.
447
+ const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
448
+ const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
441
449
 
442
450
  // A "row-scoped" condition is either a direct owner or a transitive via.
443
451
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
@@ -534,6 +542,9 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
534
542
  name: string, command: PolicyCommand, roles: string[],
535
543
  using: string | null, check: string | null,
536
544
  ): void => {
545
+ if (lifecycleGuard && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE)) {
546
+ using = andPredicates(lifecycleGuard, using) ?? lifecycleGuard;
547
+ }
537
548
  // Same normalization the live producer applies (authz-contract's `predicate`): a model
538
549
  // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
539
550
  // and it must compare equal against a stage on another. One rule table, two producers.
@@ -823,6 +834,24 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
823
834
 
824
835
  policies.sort((a, b) => a.name.localeCompare(b.name));
825
836
 
837
+ // The lifecycle restore function inlines these DELETE rules and runs them as the lifecycle
838
+ // owner. Check the text as compiled, whatever produced it (via, ownerExpr, where, sql): a
839
+ // subquery would read with that role's privileges, and current_user/session_user would not
840
+ // name the caller. defineModel refuses the common spellings first with friendlier messages.
841
+ if (model.lifecycle) {
842
+ for (const p of policies) {
843
+ if (p.command !== 'DELETE' && p.command !== 'ALL') continue;
844
+ const offender = /\bselect\b|\bcurrent_user\b|\bsession_user\b/i.exec(p.using ?? '')?.[0];
845
+ if (!offender) continue;
846
+ throw new Error(
847
+ `${table}: policy ${p.name} (${p.command}) uses ${offender} on a lifecycle: true model. Restore runs this delete rule `
848
+ + `inside a SECURITY DEFINER function as the lifecycle owner, where a subquery reads with that role's privileges and `
849
+ + `current_user/session_user do not name the caller. Use a predicate on the row's own columns or the request claims `
850
+ + `(owner without ownerExpr, or a plain where).`,
851
+ );
852
+ }
853
+ }
854
+
826
855
  return {
827
856
  table,
828
857
  // The FORCE axiom: FORCE iff the write principal is subject to its own RLS
@@ -841,6 +870,30 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
841
870
  };
842
871
  }
843
872
 
873
+ /**
874
+ * The table's DELETE rule per role: role -> the USING of every compiled DELETE or ALL policy
875
+ * naming that role, OR-ed (each parenthesized) when there are several. Read back from
876
+ * {@link compileTableContract}, so it is the policy rule by construction, never a second
877
+ * derivation. The lifecycle role is left out: it is the machinery, not an author's role.
878
+ * A policy with no USING admits nothing here, so it is skipped (fail closed).
879
+ */
880
+ export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
881
+ const byRole = new Map<string, string[]>();
882
+ for (const p of compileTableContract(model, opts).policies) {
883
+ if ((p.command !== 'DELETE' && p.command !== 'ALL') || p.using == null) continue;
884
+ for (const role of p.roles) {
885
+ if (role === LIFECYCLE_OWNER_ROLE) continue;
886
+ byRole.set(role, [...(byRole.get(role) ?? []), p.using]);
887
+ }
888
+ }
889
+ const out: Record<string, string> = {};
890
+ for (const role of [...byRole.keys()].sort()) {
891
+ const preds = byRole.get(role)!;
892
+ out[role] = preds.length === 1 ? preds[0] : preds.map((p) => `(${p})`).join(' OR ');
893
+ }
894
+ return out;
895
+ }
896
+
844
897
  /** The four DML privileges, sorted — the expansion of a `manage` ability. */
845
898
  const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
846
899
 
@@ -885,7 +938,8 @@ function compileGrants(
885
938
  abilities: readonly Ability[],
886
939
  privileges: Record<string, readonly string[]> = {},
887
940
  ): Record<string, string[]> {
888
- const grants: Record<string, Set<string>> = {};
941
+ // Null-prototype, for the same reason as compileColumnGrants.
942
+ const grants: Record<string, Set<string>> = Object.create(null);
889
943
  const add = (role: string, verbs: readonly string[]): void => {
890
944
  const set = (grants[role] ??= new Set<string>());
891
945
  for (const v of verbs) set.add(v);
@@ -907,7 +961,7 @@ function compileGrants(
907
961
  for (const [role, privs] of Object.entries(privileges)) {
908
962
  if (privs.length) add(granteeKey(role), privs);
909
963
  }
910
- const out: Record<string, string[]> = {};
964
+ const out: Record<string, string[]> = Object.create(null);
911
965
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
912
966
  return out;
913
967
  }
@@ -713,7 +713,7 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
713
713
  for (const row of rows) {
714
714
  const entry = bySequence.get(row.sequence)
715
715
  ?? {
716
- sequence: row.sequence, table: row.table, grants: {} as Record<string, string[]>,
716
+ sequence: row.sequence, table: row.table, grants: Object.create(null) as Record<string, string[]>,
717
717
  // Omitted rather than `false` on a serial sequence, so a contract read before this
718
718
  // field existed and one read after compare structurally equal.
719
719
  ...(truthy(row.is_identity) ? { identity: true } : {}),
@@ -766,15 +766,15 @@ export type DefaultAclScope = Record<string, Record<string, string[]>>;
766
766
 
767
767
  /** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
768
768
  export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
769
- const tables: DefaultAclScope = {};
770
- const sequences: DefaultAclScope = {};
769
+ const tables: DefaultAclScope = Object.create(null);
770
+ const sequences: DefaultAclScope = Object.create(null);
771
771
  for (const row of rows) {
772
772
  if (row.grantee == null || row.privilege == null) continue;
773
773
  const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
774
774
  if (!scope) continue;
775
775
  const schema = String(row.schema ?? '');
776
776
  const grantee = String(row.grantee);
777
- const byGrantee = scope[schema] ?? (scope[schema] = {});
777
+ const byGrantee = scope[schema] ?? (scope[schema] = Object.create(null));
778
778
  byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
779
779
  }
780
780
  return { tables, sequences };
@@ -796,8 +796,8 @@ export function parseSchemaAcl(text: string | null | undefined): Record<string,
796
796
  const s = String(text).trim();
797
797
  if (!s.startsWith('{') || !s.endsWith('}')) return null;
798
798
  const body = s.slice(1, -1);
799
- if (body.trim() === '') return {};
800
- const out: Record<string, string[]> = {};
799
+ if (body.trim() === '') return Object.create(null);
800
+ const out: Record<string, string[]> = Object.create(null);
801
801
  // Split items at top-level commas — a quoted region may contain commas.
802
802
  const items: string[] = [];
803
803
  let start = 0;
@@ -986,13 +986,19 @@ export interface ContractRows {
986
986
  * empty allowlist a diff would never flag. Only SECURITY DEFINER functions are
987
987
  * recorded: an INVOKER function runs as the caller under RLS and is not an
988
988
  * authorization decision point; a SECDEF function runs above RLS and is.
989
+ *
990
+ * Every map keyed by a live name (role, schema) is `Object.create(null)`. On a plain object
991
+ * `grants['__proto__'] = x` sets the prototype and adds no key, and PostgreSQL allows a quoted
992
+ * role named "__proto__": its grants vanished from this read, so the plan, the fingerprint and
993
+ * the pull could not see them. A null-prototype object serializes and iterates exactly like a
994
+ * plain one, so an ordinary database reads byte-identically.
989
995
  */
990
996
  export function assembleContract(rows: ContractRows): AuthzContract {
991
997
  const tables = new Map<string, TableContract>();
992
998
  for (const row of rows.rls) {
993
999
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
994
1000
  const { table, rls } = rlsRowToDescriptor(row);
995
- tables.set(table, { table, rls, grants: {}, policies: [] });
1001
+ tables.set(table, { table, rls, grants: Object.create(null), policies: [] });
996
1002
  }
997
1003
 
998
1004
  // A grant or policy on a table the RLS enumerator didn't list (e.g. a view, excluded
@@ -1011,8 +1017,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1011
1017
  const { table, grantee, privilege, column } = columnGrantRowToDescriptor(row);
1012
1018
  const t = tables.get(table);
1013
1019
  if (!t) continue;
1014
- const byGrantee = (t.columnGrants ??= {});
1015
- const byPriv = (byGrantee[grantee] ??= {});
1020
+ const byGrantee = (t.columnGrants ??= Object.create(null) as Record<string, Record<string, string[]>>);
1021
+ const byPriv = (byGrantee[grantee] ??= Object.create(null) as Record<string, string[]>);
1016
1022
  (byPriv[privilege] ??= []).push(column);
1017
1023
  }
1018
1024
 
@@ -1025,7 +1031,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1025
1031
  const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
1026
1032
  const t = tables.get(table);
1027
1033
  if (!t || privileges.length === 0) continue;
1028
- (t.effectivePrivileges ??= {})[role] = [...privileges].sort();
1034
+ (t.effectivePrivileges ??= Object.create(null) as Record<string, string[]>)[role] = [...privileges].sort();
1029
1035
  }
1030
1036
  }
1031
1037
 
@@ -1053,10 +1059,10 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1053
1059
  // is distinguishable from "we never looked".
1054
1060
  let schemaAcls: Record<string, Record<string, string[]>> | undefined;
1055
1061
  if (rows.schemaAcls) {
1056
- schemaAcls = {};
1062
+ schemaAcls = Object.create(null) as Record<string, Record<string, string[]>>;
1057
1063
  for (const row of rows.schemaAcls) {
1058
1064
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
1059
- schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
1065
+ schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? Object.create(null);
1060
1066
  }
1061
1067
  }
1062
1068
 
@@ -1212,9 +1218,12 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
1212
1218
 
1213
1219
  function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
1214
1220
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l.grants)]);
1221
+ // Own keys only: a declared contract loaded with JSON.parse is a plain object, where a
1222
+ // grantee the file lacks (`__proto__`, `constructor`) would read an Object.prototype member.
1223
+ const held = (grants: Record<string, string[]>, g: string): string[] => (Object.hasOwn(grants, g) ? grants[g] : []);
1215
1224
  for (const g of grantees) {
1216
- const dp = (d.grants[g] ?? []).join(',');
1217
- const lp = (l.grants[g] ?? []).join(',');
1225
+ const dp = held(d.grants, g).join(',');
1226
+ const lp = held(l.grants, g).join(',');
1218
1227
  if (dp !== lp) {
1219
1228
  out.push({ subject: table, kind: 'grant', detail: `grant to ${g}: declared [${dp}], live [${lp}]` });
1220
1229
  }