@everystack/cli 0.4.48 → 0.4.50

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.
@@ -45,6 +45,19 @@ export interface LiveObject {
45
45
  rows?: number;
46
46
  /** Trigger only: the (bare or schema-qualified) relation it rides — `DROP TRIGGER … ON` needs it. */
47
47
  table?: string;
48
+ /** Trigger only: the owner's full `schema.table`, for joining to a model or a derived view. */
49
+ ownerIdentity?: string;
50
+ /** Trigger only (R2 pull rendering): the structured binding `defineTrigger` is rendered from.
51
+ * Absent when the introspection predates R2 — absent means "not measured", never "no WHEN". */
52
+ trg?: {
53
+ shape: TriggerShape;
54
+ /** `schema.name` of the trigger function. Trigger functions take no arguments. */
55
+ functionIdentity: string;
56
+ /** `UPDATE OF (cols)` in declaration order. */
57
+ updateOf?: string[];
58
+ /** The WHEN predicate as the catalog deparses it. */
59
+ when?: string;
60
+ };
48
61
  /** Live ACLs (grantee → sorted privileges), owner excluded, function defaults expanded.
49
62
  * Absent on triggers (they take no grants) and when the introspection predates B4. */
50
63
  grants?: Record<string, string[]>;
@@ -198,13 +211,20 @@ ORDER BY n.nspname, t.relname, idx.indexrelid;
198
211
  * lookups by MANAGED derived identities, so table-referenced edges never join it.
199
212
  * System-schema referenced sides are excluded on both branches: a view reading
200
213
  * pg_catalog is not an edge anyone declares.
214
+ *
215
+ * `::text` on the first branch's name columns is the same load-bearing cast as
216
+ * DERIVED_GRANTS_SQL: later branches put `proname(argtypes)` on both the dependent and
217
+ * the referenced side, and a UNION types each column from the FIRST branch, so a bare
218
+ * `relname` (`name`, 63 bytes) would truncate a long function signature. The edge would
219
+ * then name an identity that joins nothing, and the edge is silently dropped — a wrong
220
+ * rebuild order, and a `dependsOn` the pull never writes.
201
221
  */
202
222
  export const DERIVED_DEPENDS_SQL = `
203
223
  SELECT DISTINCT
204
- dn.nspname AS dependent_schema,
205
- dc.relname AS dependent_name,
206
- rn.nspname AS referenced_schema,
207
- rc.relname AS referenced_name
224
+ dn.nspname::text AS dependent_schema,
225
+ dc.relname::text AS dependent_name,
226
+ rn.nspname::text AS referenced_schema,
227
+ rc.relname::text AS referenced_name
208
228
  FROM pg_depend d
209
229
  JOIN pg_rewrite rw ON rw.oid = d.objid
210
230
  JOIN pg_class dc ON dc.oid = rw.ev_class
@@ -282,13 +302,22 @@ WHERE d.classid = 'pg_proc'::regclass
282
302
  * `LEFT JOIN pg_roles` + COALESCE('PUBLIC') keeps a dropped-role ACL from failing the
283
303
  * read (the table path's offline-safety). `kind` disambiguates a relation and a
284
304
  * function sharing a name.
305
+ *
306
+ * `::text` on the name-typed columns is load-bearing, not tidiness. `relname` is
307
+ * PostgreSQL's `name` type (63 bytes) and a UNION takes each column's type from the
308
+ * FIRST branch, so without the cast the function branch's `proname(argtypes)` text is
309
+ * coerced DOWN to `name` and silently truncated at 63. Every consumer keys on that
310
+ * identity, so a function with ~5 ordinary parameters lost its ACL outright: db:pull
311
+ * rendered no `can('execute')`, and diffObjectGrants — which unions the DECLARED and LIVE
312
+ * grantees — never saw the live grantee, so no REVOKE was planned and reconcile reported
313
+ * converged while the grant was still live.
285
314
  */
286
315
  export const DERIVED_GRANTS_SQL = `
287
316
  SELECT
288
- n.nspname AS schema,
289
- c.relname AS name,
290
- 'r' AS kind,
291
- COALESCE(r.rolname, 'PUBLIC') AS grantee,
317
+ n.nspname::text AS schema,
318
+ c.relname::text AS name,
319
+ 'r'::text AS kind,
320
+ COALESCE(r.rolname, 'PUBLIC')::text AS grantee,
292
321
  array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
293
322
  FROM pg_class c
294
323
  JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -331,17 +360,83 @@ export const PROVENANCE_SQL = `
331
360
  SELECT identity, src_hash, def_hash, to_jsonb(p) AS extra FROM everystack.derived_provenance p;
332
361
  `.trim();
333
362
 
334
- /** User triggers (never internal/constraint machinery) with their canonical definition —
335
- * the live side of the trigger reconcile. Compound identity: schema.table.name. */
363
+ /**
364
+ * The bits PostgreSQL packs into `pg_trigger.tgtype`
365
+ * (src/include/catalog/pg_trigger.h). Timing, event set and level all share one mask.
366
+ */
367
+ export const TRIGGER_TYPE_BITS = {
368
+ row: 1, before: 2, insert: 4, delete: 8, update: 16, truncate: 32, instead: 64,
369
+ } as const;
370
+
371
+ /** The canonical event order — the model's own (`EVENT_ORDER` in model/src/derived.ts).
372
+ * Ordering by the MASK instead would render `update, insert` for one trigger and
373
+ * `insert, update` for another, and the two would hash differently while being equal. */
374
+ const TRIGGER_EVENT_ORDER = ['insert', 'update', 'delete', 'truncate'] as const;
375
+
376
+ export interface TriggerShape {
377
+ timing: 'before' | 'after' | 'insteadOf';
378
+ events: ('insert' | 'update' | 'delete' | 'truncate')[];
379
+ forEach: 'row' | 'statement';
380
+ }
381
+
382
+ /**
383
+ * `tgtype` → the shape `trigger()` declares. The bitmask is the ONLY structured source
384
+ * for a trigger's timing/events/level; `pg_get_triggerdef` is prose, and parsing prose
385
+ * back into structure is how a renderer starts disagreeing with the catalog.
386
+ *
387
+ * `instead` is checked before `before` because they are different fields sharing a mask:
388
+ * an INSTEAD OF trigger is never BEFORE, and reading the bits in the other order would
389
+ * silently render a view's INSTEAD OF trigger as a table-shaped BEFORE one.
390
+ */
391
+ export function parseTriggerType(tgtype: number): TriggerShape {
392
+ const has = (bit: number): boolean => (tgtype & bit) !== 0;
393
+ const events = TRIGGER_EVENT_ORDER.filter((e) => has(TRIGGER_TYPE_BITS[e]));
394
+ if (events.length === 0) {
395
+ throw new Error(`trigger tgtype ${tgtype} has no event bits — PostgreSQL never writes that, so the read is wrong, not the database.`);
396
+ }
397
+ return {
398
+ timing: has(TRIGGER_TYPE_BITS.instead) ? 'insteadOf' : has(TRIGGER_TYPE_BITS.before) ? 'before' : 'after',
399
+ events: [...events],
400
+ forEach: has(TRIGGER_TYPE_BITS.row) ? 'row' : 'statement',
401
+ };
402
+ }
403
+
404
+ /**
405
+ * User triggers (never internal/constraint machinery) with their canonical definition —
406
+ * the live side of the trigger reconcile. Compound identity: schema.table.name.
407
+ *
408
+ * The STRUCTURED half (`tgtype`, `update_of`, `when_expr`, the function identity) is what
409
+ * a `trigger()` binding is rendered from; the `definition` text stays for the def hash.
410
+ *
411
+ * **`when_expr` is scraped out of `pg_get_triggerdef`, and that is not laziness.**
412
+ * `pg_get_expr(t.tgqual, t.tgrelid)` — the obvious call — FAILS OUTRIGHT on any trigger
413
+ * carrying a WHEN clause that mentions both OLD and NEW:
414
+ *
415
+ * ERROR: expression contains variables of more than one relation
416
+ *
417
+ * A WHEN over two pseudo-relations is the common case (`WHEN (OLD.* IS DISTINCT FROM
418
+ * NEW.*)`), so the direct read is not merely lossy — it errors, and it errors on the
419
+ * whole query, taking every other trigger's row with it. `pg_get_triggerdef` renders the
420
+ * same expression correctly because it knows the OLD/NEW aliases. Verified against a live
421
+ * brownfield database, 2026-08-06.
422
+ */
336
423
  export const DERIVED_TRIGGERS_SQL = `
337
424
  SELECT
338
425
  n.nspname AS schema,
339
426
  c.relname AS "table",
340
427
  t.tgname AS name,
341
- pg_get_triggerdef(t.oid) AS definition
428
+ pg_get_triggerdef(t.oid) AS definition,
429
+ t.tgtype::int AS tgtype,
430
+ fn.nspname::text || '.' || p.proname::text AS function_identity,
431
+ (SELECT array_agg(a.attname::text ORDER BY x.ord)
432
+ FROM unnest(t.tgattr::int2[]) WITH ORDINALITY AS x(attnum, ord)
433
+ JOIN pg_attribute a ON a.attrelid = t.tgrelid AND a.attnum = x.attnum) AS update_of,
434
+ substring(pg_get_triggerdef(t.oid) from ' WHEN [(](.*)[)] EXECUTE ') AS when_expr
342
435
  FROM pg_trigger t
343
436
  JOIN pg_class c ON c.oid = t.tgrelid
344
437
  JOIN pg_namespace n ON n.oid = c.relnamespace
438
+ JOIN pg_proc p ON p.oid = t.tgfoid
439
+ JOIN pg_namespace fn ON fn.oid = p.pronamespace
345
440
  WHERE NOT t.tgisinternal
346
441
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
347
442
  AND n.nspname NOT LIKE 'pg_%'
@@ -409,6 +504,16 @@ export interface TriggerRow {
409
504
  table: string;
410
505
  name: string;
411
506
  definition: unknown;
507
+ /** The structured half — all optional, because a pre-R2 introspection has none of it
508
+ * and an absent field must read as "not measured", never as "no WHEN clause". */
509
+ tgtype?: unknown;
510
+ /** `schema.name` of the trigger function. Trigger functions take no arguments, so the
511
+ * bare name identifies one — no signature needed, unlike every other function read. */
512
+ function_identity?: unknown;
513
+ /** `UPDATE OF (cols)`, column names in declaration order. NULL when unqualified. */
514
+ update_of?: unknown;
515
+ /** The WHEN predicate, scraped from pg_get_triggerdef — see DERIVED_TRIGGERS_SQL. */
516
+ when_expr?: unknown;
412
517
  }
413
518
 
414
519
  export interface GrantAclRow {
@@ -547,11 +652,21 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
547
652
  const ownerIdentity = `${row.schema}.${row.table}`;
548
653
  const identity = `${ownerIdentity}.${row.name}`;
549
654
  const definition = String(row.definition ?? '');
655
+ const updateOf = pgTextArray(row.update_of);
550
656
  objects.push({
551
657
  kind: 'trigger', schema: row.schema, name: row.name, identity,
552
658
  definition, indexes: [],
553
659
  defHash: computeDefHash(definition, []),
554
660
  table: row.schema === 'public' ? row.table : ownerIdentity,
661
+ ownerIdentity,
662
+ ...(row.tgtype != null && row.function_identity != null ? {
663
+ trg: {
664
+ shape: parseTriggerType(Number(row.tgtype)),
665
+ functionIdentity: String(row.function_identity),
666
+ ...(updateOf.length ? { updateOf } : {}),
667
+ ...(row.when_expr != null ? { when: String(row.when_expr) } : {}),
668
+ },
669
+ } : {}),
555
670
  });
556
671
  if (relationIdentities.has(ownerIdentity)) {
557
672
  triggerEdges.push({ dependent: identity, referenced: ownerIdentity });
@@ -32,6 +32,7 @@ import type {
32
32
  FunctionDescriptor, DependsOnRef,
33
33
  } from '@everystack/model';
34
34
  import { parseQualified } from './derived-source.js';
35
+ import { GOVERNED_VOCABULARY } from './authz-derive.js';
35
36
 
36
37
  export interface DerivedAuthzGap {
37
38
  /** `schema.name` of the descriptor that fails the gate. */
@@ -60,6 +61,10 @@ function isModelRef(ref: DependsOnRef): ref is ModelDescriptor {
60
61
  * as zero-reach, which bricked every invoker view over a private table.
61
62
  */
62
63
  export function tableReaches(m: ModelDescriptor, role: string): boolean {
64
+ // A recorded grant is reach too. PUBLIC SELECT is held by every role; a named grantee
65
+ // reaches itself. See grantsPublic — this is the half the gate kept forgetting.
66
+ if (grantsPublic(m.privileges, 'SELECT')) return true;
67
+ if (privilegeRoles(m.privileges, 'SELECT').includes(role)) return true;
63
68
  for (const a of m.abilities) {
64
69
  if (isColumnAbility(a)) {
65
70
  // Only the READ half reaches: a column-scoped update compiles to `UPDATE (cols)`
@@ -79,6 +84,30 @@ export function tableReaches(m: ModelDescriptor, role: string): boolean {
79
84
  return false;
80
85
  }
81
86
 
87
+ /**
88
+ * REACH comes from abilities AND from recorded privileges. Forgetting the second half is a
89
+ * bug this repo has now made three times — the 63-byte ACL truncation, the SECDEF caller
90
+ * gate, and this one — always the same shape: a fact recorded as `privileges` falls out of
91
+ * a downstream read and lands in neither set.
92
+ *
93
+ * `PUBLIC` is the case that matters. `GRANT … TO PUBLIC` is held by EVERY role, so a
94
+ * dependency carrying one is reachable by all of them, and a live PUBLIC grant is exactly
95
+ * what `db:pull` RECORDS rather than inventing an audience for. Reading abilities alone
96
+ * made a faithful pull fail to COMPILE, with no hand-edit available to fix it.
97
+ */
98
+ function grantsPublic(privileges: Record<string, string[]> | undefined, privilege: string): boolean {
99
+ return Object.entries(privileges ?? {}).some(
100
+ ([grantee, privs]) => grantee.toUpperCase() === 'PUBLIC' && privs.some((p) => p.toUpperCase() === privilege),
101
+ );
102
+ }
103
+
104
+ /** Grantees a recorded privilege names directly — reach for that role, nobody else. */
105
+ function privilegeRoles(privileges: Record<string, string[]> | undefined, privilege: string): string[] {
106
+ return Object.entries(privileges ?? {})
107
+ .filter(([, privs]) => privs.some((p) => p.toUpperCase() === privilege))
108
+ .map(([grantee]) => grantee);
109
+ }
110
+
82
111
  /** The roles a relation's grants reach — bare read is anon + authenticated (the table precedent). */
83
112
  function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<string> {
84
113
  const roles = new Set<string>();
@@ -89,12 +118,15 @@ function relationRoles(d: ViewDescriptor | MaterializedViewDescriptor): Set<stri
89
118
  roles.add('authenticated');
90
119
  }
91
120
  }
121
+ for (const r of privilegeRoles(d.privileges, 'SELECT')) roles.add(r);
92
122
  return roles;
93
123
  }
94
124
 
95
125
  function functionRoles(fn: FunctionDescriptor): Set<string> {
96
126
  // define-time already rejected bare can('execute') — every ability carries a role.
97
- return new Set(fn.abilities.map((a) => a.condition.role!));
127
+ const roles = new Set(fn.abilities.map((a) => a.condition.role!));
128
+ for (const r of privilegeRoles(fn.privileges, 'EXECUTE')) roles.add(r);
129
+ return roles;
98
130
  }
99
131
 
100
132
  // ---------------------------------------------------------------------------
@@ -132,11 +164,32 @@ export function findDerivedReadGaps(derived: readonly DerivedDescriptor[]): Deri
132
164
  * is privileged code with an undeclared audience — callable by nobody today (PUBLIC is
133
165
  * revoked unconditionally) and by whoever gets a grant tomorrow, with no declaration to
134
166
  * review. Invoker functions may stay dark: unprivileged, owner-callable, honest.
167
+ *
168
+ * TRIGGER FUNCTIONS ARE EXEMPT, and the exemption is structural rather than a courtesy.
169
+ * A trigger function has no caller role to declare: PostgreSQL performs no EXECUTE check
170
+ * on the invoking role when a trigger fires, so its audience is the table event, not a
171
+ * grantee. Live ones therefore carry no EXECUTE grant, and the gate could only be
172
+ * satisfied by declaring one — which compiles to a GRANT the database never had. That is
173
+ * the same widening this module's read/manage siblings exist to prevent, arriving on the
174
+ * function branch, and it made a faithful pull of a SECDEF trigger function unshippable:
175
+ * the adopter's only way past db:check was to widen their own database.
176
+ *
177
+ * The discriminator is `returns === 'trigger'` — the return type, never the name. It is
178
+ * the same fact `trigger()` already relies on (model/src/derived.ts, which refuses an
179
+ * `execute:` whose function does not return `trigger`), so the two agree by construction.
180
+ *
181
+ * A RECORDED grant counts as a declared caller too. `privileges: { PUBLIC: ['EXECUTE'] }`
182
+ * is `db:pull` saying "the database grants this" — the audience IS declared and reviewable,
183
+ * it simply was not chosen by the author. The gap gate is about undeclared audience, so it
184
+ * stops firing; the objection moves to `findPublicExecutableSecdef` below, which is a
185
+ * warning. Abilities are opinion, privileges are fact, and the linter holds the opinions.
135
186
  */
136
187
  export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
137
188
  const gaps: DerivedAuthzGap[] = [];
138
189
  for (const d of derived) {
139
190
  if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
191
+ if (d.returns === 'trigger') continue;
192
+ if (Object.keys(d.privileges ?? {}).length > 0) continue;
140
193
  const identity = identityOf(d);
141
194
  gaps.push({
142
195
  identity,
@@ -148,6 +201,38 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
148
201
  return gaps;
149
202
  }
150
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // Gate: SECURITY DEFINER executable by PUBLIC (db:check warn)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Privileged code any role can call. This is where the objection to a PUBLIC EXECUTE grant
210
+ * lives now that the grant is spellable — a WARN, not a failure, because on an adopted
211
+ * database it is a faithful report of what is already true, and failing CI on a faithful
212
+ * pull would make `db:pull` unusable on exactly the schemas it exists for. (Same call, and
213
+ * the same reasoning, as `findNakedGrants` on the table side.)
214
+ *
215
+ * Only DEFINER functions: an invoker function executes with the caller's own rights, so
216
+ * PUBLIC EXECUTE on one grants no authority the caller did not already have.
217
+ */
218
+ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]): DerivedAuthzGap[] {
219
+ const warnings: DerivedAuthzGap[] = [];
220
+ for (const d of derived) {
221
+ if (d.kind !== 'function' || d.security !== 'definer') continue;
222
+ if (d.returns === 'trigger') continue;
223
+ if (!Object.keys(d.privileges ?? {}).some((g) => g.toUpperCase() === 'PUBLIC')) continue;
224
+ const identity = identityOf(d);
225
+ warnings.push({
226
+ identity,
227
+ message:
228
+ `${identity} is SECURITY DEFINER and executable by PUBLIC — it runs with its owner's rights and every ` +
229
+ `role can call it, including anon. This is recorded as live fact, not a choice: confirm the body is safe ` +
230
+ `for an untrusted caller, then narrow it to can('execute', { role: '…' }) and drop the PUBLIC privilege.`,
231
+ });
232
+ }
233
+ return warnings;
234
+ }
235
+
151
236
  // ---------------------------------------------------------------------------
152
237
  // Gate: matview snapshot over row-scoped sources (db:check warn)
153
238
  // ---------------------------------------------------------------------------
@@ -209,7 +294,7 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
209
294
  }
210
295
  switch (ref.kind) {
211
296
  case 'view': {
212
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
297
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
213
298
  // An invoker dep re-checks the caller one level down; a definer dep reads as its owner.
214
299
  if (ref.securityInvoker) {
215
300
  const deeper = findUnreachable(role, ref.dependsOn, seen);
@@ -219,11 +304,11 @@ function findUnreachable(role: string, refs: readonly DependsOnRef[], seen: Set<
219
304
  }
220
305
  case 'materialized view':
221
306
  // A matview is its own snapshot: SELECT on the matview is the whole requirement.
222
- if (ref.private || !relationRoles(ref).has(role)) return identityOf(ref);
307
+ if (ref.private || (!relationRoles(ref).has(role) && !grantsPublic(ref.privileges, 'SELECT'))) return identityOf(ref);
223
308
  break;
224
309
  case 'function':
225
310
  // The body calls it with the caller's rights — EXECUTE is part of reach.
226
- if (!functionRoles(ref).has(role)) return identityOf(ref);
311
+ if (!functionRoles(ref).has(role) && !grantsPublic(ref.privileges, 'EXECUTE')) return identityOf(ref);
227
312
  break;
228
313
  case 'sql':
229
314
  break; // opaque — never a false positive
@@ -243,7 +328,12 @@ export function findInvokerReachabilityGaps(derived: readonly DerivedDescriptor[
243
328
  for (const d of derived) {
244
329
  if (d.kind !== 'view' || !d.securityInvoker || d.private || d.abilities.length === 0) continue;
245
330
  const identity = identityOf(d);
246
- for (const role of [...relationRoles(d)].sort()) {
331
+ // Only roles the declared world can SATISFY. A grantee outside the compiler's
332
+ // vocabulary is recorded fact on the view AND on the table (the tables path never
333
+ // renders one as an ability), so table-side reach for it is not expressible — checking
334
+ // it is a demand no model can ever meet. Their reach is adopted via authz-baseline,
335
+ // outside this gate. PUBLIC is governed and stays checked.
336
+ for (const role of [...relationRoles(d)].filter((r) => GOVERNED_VOCABULARY.has(r)).sort()) {
247
337
  const unreachable = findUnreachable(role, d.dependsOn, new Set());
248
338
  if (unreachable) {
249
339
  gaps.push({