@everystack/cli 0.4.52 → 0.4.53

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.52",
3
+ "version": "0.4.53",
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>",
@@ -23,6 +23,7 @@
23
23
  import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
25
25
  import { parenthesizeOnce } from './authz-contract.js';
26
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
26
27
 
27
28
  export interface CompileOptions {
28
29
  /** Schema the table lives in. Default: `public`. */
@@ -400,7 +401,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
400
401
  name: string, command: PolicyCommand, roles: string[],
401
402
  using: string | null, check: string | null,
402
403
  ): void => {
403
- policies.push({ name, command, roles, permissive: true, using, check });
404
+ // Same normalization the live producer applies (authz-contract's `predicate`): a model
405
+ // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
406
+ // and it must compare equal against a stage on another. One rule table, two producers.
407
+ policies.push({
408
+ name, command, roles, permissive: true,
409
+ using: using == null ? null : normalizeDeparsedExpr(using),
410
+ check: check == null ? null : normalizeDeparsedExpr(check),
411
+ });
404
412
  };
405
413
 
406
414
  // admin-bypass — one ALL policy, true/true.
@@ -24,6 +24,7 @@
24
24
  import { parsePgArray } from './security-catalog.js';
25
25
  import { INTROSPECTION_SESSION, type SessionRunner } from './session.js';
26
26
  import { matchPolicies, roleSetEqual } from './authz-identity.js';
27
+ import { normalizeDeparsedExpr } from './deparse-normal.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // The contract format — the frozen, reviewable, version-controlled shape.
@@ -270,6 +271,15 @@ export interface FunctionContract {
270
271
  export interface AuthzContract {
271
272
  tables: TableContract[];
272
273
  functions: FunctionContract[];
274
+ /**
275
+ * Schema-level ACLs (pg_namespace.nspacl), schema → grantee → sorted privileges
276
+ * (USAGE | CREATE). `PUBLIC` is the pseudo-role entry. A schema with a NULL acl is
277
+ * ABSENT — owner-default, no explicit grants — so absence means "the grant does not
278
+ * exist" and an emitter may emit it. Optional: contracts assembled from older recorders
279
+ * or hand-built fixtures simply carry no schema knowledge, and consumers must treat
280
+ * that as "unknown", never as "no grants".
281
+ */
282
+ schemaAcls?: Record<string, Record<string, string[]>>;
273
283
  }
274
284
 
275
285
  export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
@@ -398,6 +408,82 @@ WHERE c.relkind = 'r'
398
408
  ORDER BY n.nspname, c.relname;
399
409
  `.trim();
400
410
 
411
+ /**
412
+ * Schema ACLs, for the emitters that grant schema USAGE. The consumer bug this closes: the
413
+ * usage phase emitted `GRANT USAGE ON SCHEMA` with no live read at all, so every plan with
414
+ * any authz statement re-granted what the database already held — two false statements on
415
+ * every stage plan, forever. `nspacl` casts to its array-literal text; NULL stays NULL
416
+ * (owner default — no explicit grants — which the parser must NOT read as an empty grant
417
+ * list on purpose: for nspacl the two mean the same emittable thing, but the distinction
418
+ * is kept so the contract says what the catalog said).
419
+ */
420
+ export const SCHEMA_ACL_SQL = `
421
+ SELECT n.nspname AS schema, n.nspacl::text AS acl
422
+ FROM pg_namespace n
423
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
424
+ AND n.nspname NOT LIKE 'pg_%'
425
+ ORDER BY n.nspname;
426
+ `.trim();
427
+
428
+ export interface SchemaAclRow {
429
+ schema: string;
430
+ acl: unknown;
431
+ }
432
+
433
+ /** The two privileges a schema ACL can carry, in aclitem letter form. */
434
+ const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
435
+
436
+ /**
437
+ * Parse one `aclitem[]::text` literal — `{postgres=UC/postgres,authenticator=U/postgres}` —
438
+ * into grantee → privileges. An empty grantee (`=U/postgres`) is PUBLIC. A quoted grantee
439
+ * (`"odd,role"=U/postgres`) is unwrapped with its doubled-quote escapes. A `*` (grant
440
+ * option) rides the letter before it and is dropped — holding WITH GRANT OPTION still
441
+ * holds the privilege. Unparseable input returns null: the caller treats it as unknown,
442
+ * never as "no grants".
443
+ */
444
+ export function parseSchemaAcl(text: string | null | undefined): Record<string, string[]> | null {
445
+ if (text == null) return null;
446
+ const s = String(text).trim();
447
+ if (!s.startsWith('{') || !s.endsWith('}')) return null;
448
+ const body = s.slice(1, -1);
449
+ if (body.trim() === '') return {};
450
+ const out: Record<string, string[]> = {};
451
+ // Split items at top-level commas — a quoted region may contain commas.
452
+ const items: string[] = [];
453
+ let start = 0;
454
+ for (let j = 0; j < body.length; j++) {
455
+ if (body[j] === '"') {
456
+ for (j++; j < body.length; j++) {
457
+ if (body[j] !== '"') continue;
458
+ if (body[j + 1] === '"') { j++; continue; }
459
+ break;
460
+ }
461
+ } else if (body[j] === ',') {
462
+ items.push(body.slice(start, j));
463
+ start = j + 1;
464
+ }
465
+ }
466
+ items.push(body.slice(start));
467
+ for (const item of items) {
468
+ // grantee=letters/grantor — grantee may be quoted; the grantor half is irrelevant here.
469
+ const eq = item.indexOf('=', item.startsWith('"') ? item.indexOf('"', 1) + 1 : 0);
470
+ if (eq === -1) return null;
471
+ let grantee = item.slice(0, eq);
472
+ if (grantee.startsWith('"') && grantee.endsWith('"')) grantee = grantee.slice(1, -1).replace(/""/g, '"');
473
+ if (grantee === '') grantee = 'PUBLIC';
474
+ const slash = item.indexOf('/', eq);
475
+ const letters = item.slice(eq + 1, slash === -1 ? undefined : slash);
476
+ const privs = new Set<string>();
477
+ for (const ch of letters) {
478
+ if (ch === '*') continue;
479
+ const p = SCHEMA_PRIV_LETTERS[ch];
480
+ if (p) privs.add(p);
481
+ }
482
+ if (privs.size) out[grantee] = [...privs].sort();
483
+ }
484
+ return out;
485
+ }
486
+
401
487
  // ---------------------------------------------------------------------------
402
488
  // Pure mappers — one catalog row -> one descriptor.
403
489
  // ---------------------------------------------------------------------------
@@ -411,7 +497,10 @@ function truthy(v: unknown): boolean {
411
497
  function predicate(v: unknown): string | null {
412
498
  if (v == null) return null;
413
499
  const s = String(v).trim();
414
- return s.length > 0 ? s : null;
500
+ // Normalized at the PRODUCER, so the matcher, the canonical hash, and the drift detail
501
+ // all see one spelling — a live tree deparsed by an older major must compare equal to
502
+ // the same predicate parsed on a newer one. See deparse-normal.ts for the rule table.
503
+ return s.length > 0 ? normalizeDeparsedExpr(s) : null;
415
504
  }
416
505
 
417
506
  export interface PolicyRow {
@@ -524,6 +613,8 @@ export interface ContractRows {
524
613
  policies: PolicyRow[];
525
614
  grants: GrantRow[];
526
615
  columnGrants?: ColumnGrantRow[];
616
+ /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
617
+ schemaAcls?: SchemaAclRow[];
527
618
  /** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
528
619
  functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
529
620
  }
@@ -585,9 +676,22 @@ export function assembleContract(rows: ContractRows): AuthzContract {
585
676
  }))
586
677
  .sort((a, b) => a.name.localeCompare(b.name));
587
678
 
679
+ // Schema ACLs, keyed only when the read ran (absent = unknown, per the contract's doc).
680
+ // A NULL acl (owner default) contributes an empty entry so "we looked, nothing granted"
681
+ // is distinguishable from "we never looked".
682
+ let schemaAcls: Record<string, Record<string, string[]>> | undefined;
683
+ if (rows.schemaAcls) {
684
+ schemaAcls = {};
685
+ for (const row of rows.schemaAcls) {
686
+ if (IGNORED_SCHEMAS.has(row.schema)) continue;
687
+ schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
688
+ }
689
+ }
690
+
588
691
  return {
589
692
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
590
693
  functions,
694
+ ...(schemaAcls ? { schemaAcls } : {}),
591
695
  };
592
696
  }
593
697
 
@@ -597,11 +701,11 @@ export async function introspectContract(
597
701
  mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
598
702
  functionsSql: string,
599
703
  ): Promise<AuthzContract> {
600
- // ONE session: the five queries describe one moment under one pinned search_path.
704
+ // ONE session: the six queries describe one moment under one pinned search_path.
601
705
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
602
706
  // across connections can report authz drift that does not exist.
603
- const [rls, policies, grants, columnGrants, fnRows] = await session(
604
- [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, functionsSql],
707
+ const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
708
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
605
709
  INTROSPECTION_SESSION,
606
710
  );
607
711
  return assembleContract({
@@ -609,6 +713,7 @@ export async function introspectContract(
609
713
  policies: policies as PolicyRow[],
610
714
  grants: grants as GrantRow[],
611
715
  columnGrants: columnGrants as ColumnGrantRow[],
716
+ schemaAcls: schemaAcls as SchemaAclRow[],
612
717
  functions: (fnRows as any[]).map(mapFunctionRow),
613
718
  });
614
719
  }
@@ -440,6 +440,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
440
440
  const stageName = flags.stage;
441
441
  if (!stageName) {
442
442
  note(`No --stage, so ${BASELINE_FILE} was not written. The foreign grantees above are recorded per stage; re-run with --stage <name> to adopt them, or db:plan will refuse until you do.`);
443
+ note(`--stage composes with --database-url: the URL stays the connection, the stage only LABELS the baseline entry — a local clone can adopt for a deployed stage without touching it.`);
443
444
  } else {
444
445
  const entry = buildStageBaseline(pulledExemptions, { observedAt: new Date().toISOString(), fingerprint: pulledFingerprint });
445
446
  const merged = mergeBaseline(await readBaselineFile(), stageName, entry);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * deparse-normal — cross-version normalization of `pg_get_expr` output.
3
+ *
4
+ * A policy predicate's stored tree is built by whichever server PARSED the DDL, and
5
+ * different PostgreSQL majors const-fold the same source differently. The measured case
6
+ * (a consumer's stage, 2026-08-08): an older major stores an array cast OUTSIDE the array,
7
+ *
8
+ * (ARRAY['a'::character varying, 'b'::character varying])::text[]
9
+ *
10
+ * while PG17 distributes it over the elements at parse time,
11
+ *
12
+ * ARRAY[('a'::character varying)::text, ('b'::character varying)::text]
13
+ *
14
+ * Text-identity across venues is then unreachable: models pulled on one major can never
15
+ * equal a stage on another, the differ plans DROP+CREATE forever (the stage re-deparses in
16
+ * its own spelling), and the fingerprint gate cannot MATCH. Identity must not depend on the
17
+ * two venues sharing a parser.
18
+ *
19
+ * THE RULE TABLE IS DELIBERATELY SMALL. Each rewrite must be provably semantics-preserving,
20
+ * because this text feeds the policy matcher, whose safety property is "misses are safe,
21
+ * false matches are the disaster". One rule today:
22
+ *
23
+ * ARRAY-CAST DISTRIBUTION: `(ARRAY[e1, …, en])::T[] == ARRAY[(e1)::T, …, (en)::T]`
24
+ * for n ≥ 1. PostgreSQL defines an array-to-array cast element-wise (parse_coerce), so
25
+ * the two expressions denote the same value for every input; wrapping an element in
26
+ * parentheses is parse-neutral. The empty array is EXCLUDED: `ARRAY[]` without a cast has
27
+ * no type, so its cast is load-bearing and stays.
28
+ *
29
+ * Anything the scanner does not positively recognize is left byte-for-byte unchanged — an
30
+ * unrecognized spelling is a MISS (drop + create, the pre-existing behavior), never a guess.
31
+ * Escalation is named, not implied: the SECOND cross-version variant found in the field is
32
+ * the trigger to adopt libpg_query and move identity onto normalized ASTs (Ty, 2026-08-09) —
33
+ * a growing rule table over raw text is where scanning stops being defensible.
34
+ *
35
+ * Applied at the PRODUCERS (live introspection's `predicate()`, the compiler's policy
36
+ * assembly), so every downstream comparison — the identity matcher, the canonical hash, the
37
+ * drift detail — sees normalized text without holding its own copy of this rule.
38
+ */
39
+
40
+ /** `::text[]` / `::character varying[]` — the cast suffix after `(ARRAY[…])`. */
41
+ const ARRAY_CAST_SUFFIX = /^::([A-Za-z_][A-Za-z0-9_]*(?:\s[A-Za-z_][A-Za-z0-9_]*)*)\[\]/;
42
+
43
+ /**
44
+ * From an opening single or double quote, the index of its closing quote.
45
+ * SQL escapes a quote by doubling it; a doubled quote is content, not a close.
46
+ * Returns -1 on an unterminated literal (the caller then leaves the text alone).
47
+ */
48
+ function skipQuoted(s: string, at: number): number {
49
+ const q = s[at];
50
+ for (let j = at + 1; j < s.length; j++) {
51
+ if (s[j] !== q) continue;
52
+ if (s[j + 1] === q) { j++; continue; }
53
+ return j;
54
+ }
55
+ return -1;
56
+ }
57
+
58
+ /** The index of the `]` closing the `[` at `open`, honoring nesting and quoted regions. */
59
+ function matchBracket(s: string, open: number): number {
60
+ let sq = 0;
61
+ let par = 0;
62
+ for (let j = open; j < s.length; j++) {
63
+ const c = s[j];
64
+ if (c === "'" || c === '"') {
65
+ j = skipQuoted(s, j);
66
+ if (j < 0) return -1;
67
+ } else if (c === '[') sq++;
68
+ else if (c === ']') { sq--; if (sq === 0 && par === 0) return j; }
69
+ else if (c === '(') par++;
70
+ else if (c === ')') par--;
71
+ }
72
+ return -1;
73
+ }
74
+
75
+ /** Split on top-level commas (both nesting depths zero), or null on an unterminated literal. */
76
+ function splitTopLevel(s: string): string[] | null {
77
+ const out: string[] = [];
78
+ let start = 0;
79
+ let sq = 0;
80
+ let par = 0;
81
+ for (let j = 0; j < s.length; j++) {
82
+ const c = s[j];
83
+ if (c === "'" || c === '"') {
84
+ j = skipQuoted(s, j);
85
+ if (j < 0) return null;
86
+ } else if (c === '[') sq++;
87
+ else if (c === ']') sq--;
88
+ else if (c === '(') par++;
89
+ else if (c === ')') par--;
90
+ else if (c === ',' && sq === 0 && par === 0) {
91
+ out.push(s.slice(start, j));
92
+ start = j + 1;
93
+ }
94
+ }
95
+ out.push(s.slice(start));
96
+ return out;
97
+ }
98
+
99
+ /** One left-to-right pass: rewrite the first recognized `(ARRAY[…])::T[]`, or null if none. */
100
+ function distributeOnce(s: string): string | null {
101
+ let i = 0;
102
+ while ((i = s.indexOf('(ARRAY[', i)) !== -1) {
103
+ const open = i + 6; // the '['
104
+ const close = matchBracket(s, open);
105
+ if (close === -1) return null; // unparseable text — leave everything alone
106
+ if (s[close + 1] === ')') {
107
+ const m = ARRAY_CAST_SUFFIX.exec(s.slice(close + 2));
108
+ if (m) {
109
+ const inner = s.slice(open + 1, close);
110
+ const elements = splitTopLevel(inner);
111
+ // Empty array excluded: its cast carries the type. A failed split leaves the text alone.
112
+ if (elements !== null && inner.trim().length > 0) {
113
+ const t = m[1];
114
+ const rewritten = `ARRAY[${elements.map((e) => `(${e.trim()})::${t}`).join(', ')}]`;
115
+ return s.slice(0, i) + rewritten + s.slice(close + 2 + m[0].length);
116
+ }
117
+ }
118
+ }
119
+ i = open;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Normalize one deparsed expression. Idempotent; unrecognized text returns unchanged.
126
+ * Fixpoint-bounded: nesting deeper than the cap returns its progress, which is still
127
+ * deterministic — both sides of every comparison run this same function.
128
+ */
129
+ export function normalizeDeparsedExpr(text: string): string {
130
+ let s = text;
131
+ for (let n = 0; n < 64; n++) {
132
+ const next = distributeOnce(s);
133
+ if (next === null) return s;
134
+ s = next;
135
+ }
136
+ return s;
137
+ }
@@ -90,7 +90,11 @@ function holdDrop(sql: string): string {
90
90
  */
91
91
  export function unmodeledTables(models: ModelDescriptor[], current: SchemaSnapshot, opts: GenerateOptions = {}): string[] {
92
92
  const schema = opts.schema ?? 'public';
93
- const declared = new Set(models.map((m) => `${schema}.${m.table}`));
93
+ // The model's OWN schema first — the same rule the compiler applies. Qualifying every
94
+ // model with the call default read a multi-schema checkout's non-public models as
95
+ // unmodeled: a consumer's plan listed three modeled tables as riding through untouched,
96
+ // which is a false statement about what the plan governs.
97
+ const declared = new Set(models.map((m) => `${m.schema ?? schema}.${m.table}`));
94
98
  // A pending table rename's OR move's source is ours — declared under its new (qualified)
95
99
  // name; without this it reads as an undeclared orphan (F1) and the move/rename silently
96
100
  // degrades to CREATE + leave-behind, the exact bug the markers exist to prevent.
@@ -233,6 +237,20 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
233
237
  // The from-scratch path had this from the start; the diff path did not, so a non-public
234
238
  // model reached through db:sync/db:generate was unreachable by every role that did not
235
239
  // pick up USAGE some other way. The example app's analytics schema is what surfaced it.
240
+ // Diffed against the LIVE schema ACLs when the contract carries them. This phase used to
241
+ // emit unconditionally, so any plan with an authz statement re-granted USAGE the database
242
+ // already held — two false statements on every one of a consumer's stage plans (measured,
243
+ // catalog-verified, 2026-08-08). A role holds USAGE when its own nspacl entry says so or
244
+ // when PUBLIC's does (PUBLIC reaches every role). Membership-derived usage is invisible
245
+ // here and stays emitted — idempotent, and rarer than the direct grants that were the bug.
246
+ // No schemaAcls (fresh compose, older recorder) = unknown = emit, exactly as before.
247
+ const liveSchemaAcls = liveAuthzRenamed?.schemaAcls;
248
+ const hasLiveUsage = (s: string, role: string): boolean => {
249
+ const acl = liveSchemaAcls?.[s];
250
+ if (!acl) return false;
251
+ const key = role.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : role;
252
+ return (acl[key] ?? []).includes('USAGE') || (acl.PUBLIC ?? []).includes('USAGE');
253
+ };
236
254
  const usagePhase = authzPhase.length
237
255
  ? [...new Set(desiredContracts.map((c) => c.table.split('.')[0]))]
238
256
  .filter((s) => s !== 'public')
@@ -244,8 +262,9 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
244
262
  for (const r of Object.keys(c.grants)) roles.add(r);
245
263
  for (const r of Object.keys(c.columnGrants ?? {})) roles.add(r);
246
264
  }
247
- if (!roles.size) return [];
248
- const targets = [...roles].sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
265
+ const missing = [...roles].filter((r) => !hasLiveUsage(s, r));
266
+ if (!missing.length) return [];
267
+ const targets = missing.sort().map((r) => (r.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : r)).join(', ');
249
268
  return [`GRANT USAGE ON SCHEMA "${s}" TO ${targets}`];
250
269
  })
251
270
  : [];
@@ -110,7 +110,7 @@ import { normalizeDefault, normalizeCheck, indexKey } from './schema-diff.js';
110
110
  // it from the canonical form AND the reconciler leaves it alone, before and after. Restrictive
111
111
  // policies are never subsumed (they AND, so removing one would WIDEN), and the rule must match
112
112
  // exactly including the effective WITH CHECK.
113
- export const FINGERPRINT_VERSION = 8;
113
+ export const FINGERPRINT_VERSION = 9;
114
114
 
115
115
  // ---------------------------------------------------------------------------
116
116
  // Canonical form.