@everystack/cli 0.4.53 → 0.4.56

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.53",
3
+ "version": "0.4.56",
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.14"
112
+ "@everystack/model": "0.4.15"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -164,6 +164,7 @@
164
164
  "scripts": {
165
165
  "test": "jest",
166
166
  "build": "tsc --build",
167
- "lint": "tsc --noEmit"
167
+ "lint": "tsc --noEmit",
168
+ "check:artifact": "tsx scripts/check-generated-artifact.ts"
168
169
  }
169
170
  }
@@ -265,6 +265,27 @@ export interface FunctionContract {
265
265
  * operator. Pinned search_path is necessary but not sufficient.
266
266
  */
267
267
  ownerBypassesRls?: boolean;
268
+ /**
269
+ * SUPERUSER (or rds_superuser member) specifically — DIFFED.
270
+ *
271
+ * A deliberately-scoped operator role is commonly BYPASSRLS by design, so
272
+ * `ownerBypassesRls` reads `true` both for that correct design and for a function left
273
+ * owned by whoever ran the build. Identical value, wildly different blast radius: a
274
+ * consumer with 60 correctly-owned SECDEF functions saw the same `true` they would see if
275
+ * every one of them had silently moved to a superuser. This separates the two, and it is
276
+ * portable — no role name is compared.
277
+ */
278
+ ownerIsSuperuser?: boolean;
279
+ /**
280
+ * The owning role's NAME — RECORDED AND RENDERED, NEVER DIFFED.
281
+ *
282
+ * The name legitimately differs between a dev machine, a deployed master and a normalized
283
+ * operator, so comparing it would false-fire on every environment — that is why it was
284
+ * originally left out entirely. But omitting it also left a reader unable to tell
285
+ * "owned by our scoped operator" from "owned by a superuser" at a glance. It is carried
286
+ * for reading; `ownerIsSuperuser` is what the gate compares.
287
+ */
288
+ owner?: string;
268
289
  }
269
290
 
270
291
  /** The whole contract: every table and every function, as introspected. */
@@ -280,6 +301,50 @@ export interface AuthzContract {
280
301
  * that as "unknown", never as "no grants".
281
302
  */
282
303
  schemaAcls?: Record<string, Record<string, string[]>>;
304
+ /**
305
+ * The sequences behind serial/identity columns, with their live ACLs.
306
+ *
307
+ * These are INVISIBLE to every other read here: `schema-introspect`'s SEQUENCES_SQL
308
+ * deliberately excludes column-owned sequences (they are an implementation detail of the
309
+ * column, not a declarable object), and GRANTS_SQL covers relkind r/v/m only. So nothing
310
+ * carried their privileges, and a role that could INSERT into a serial-PK table could not
311
+ * draw its sequence value: every INSERT failed `permission denied for sequence`.
312
+ *
313
+ * Absent means NOT MEASURED (older recorder, hand-built fixture) and must never be read as
314
+ * "no sequences" — the emitter skips the whole pass rather than plan revokes against a set
315
+ * it did not read.
316
+ */
317
+ sequenceGrants?: SequenceGrantContract[];
318
+ }
319
+
320
+ /**
321
+ * One serial/identity sequence and the privileges it actually holds.
322
+ *
323
+ * The sequence is not declared anywhere — there is no `sequence:` key in the model and nobody
324
+ * wants one. Its privileges are DERIVED: they follow the owning table's declared grants. So
325
+ * this carries the live half plus the ownership edge that says which table to derive from.
326
+ */
327
+ export interface SequenceGrantContract {
328
+ /** `schema.sequence_name`. */
329
+ sequence: string;
330
+ /** `schema.table` of the column that owns it — whose grants this sequence inherits. */
331
+ table: string;
332
+ /** Grantee → sorted privileges (USAGE | SELECT | UPDATE). Owner excluded, as in GRANTS_SQL. */
333
+ grants: Record<string, string[]>;
334
+ /**
335
+ * The owning column is `GENERATED … AS IDENTITY`, not `serial` — and that changes who needs
336
+ * what. A serial column's value comes from a DEFAULT `nextval(...)` evaluated with the
337
+ * CALLER's privileges, so an INSERTing role must hold USAGE/SELECT/UPDATE on the sequence or
338
+ * every insert fails `permission denied for sequence`. An identity column's value is supplied
339
+ * by the system as part of the column definition; no caller privilege is consulted at all.
340
+ *
341
+ * Measured, not assumed: a role with INSERT on the table and NOTHING on the sequence inserts
342
+ * successfully into an identity table and fails on a serial one. So the A6 inheritance rule
343
+ * applies to serial sequences only — see `reconcileSequenceGrants`.
344
+ *
345
+ * Absent on a read that predates this field (treated as serial, the prior behaviour).
346
+ */
347
+ identity?: boolean;
283
348
  }
284
349
 
285
350
  export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
@@ -430,6 +495,78 @@ export interface SchemaAclRow {
430
495
  acl: unknown;
431
496
  }
432
497
 
498
+ /**
499
+ * The sequences behind serial/identity columns, with their owning table and their ACL.
500
+ *
501
+ * `pg_depend` with `deptype IN ('a','i')` and `refobjsubid > 0` is the column-ownership edge —
502
+ * the same predicate `schema-introspect`'s SEQUENCES_SQL uses to EXCLUDE these, inverted. 'a'
503
+ * is a serial's auto dependency, 'i' an identity column's internal one; both are "this sequence
504
+ * belongs to that column", and missing either would silently drop half the sequences.
505
+ *
506
+ * LEFT JOIN LATERAL, not CROSS JOIN: a sequence with a NULL acl (owner-only, the common case
507
+ * before this fix) must still come back as a row. Otherwise "no grants" and "no such sequence"
508
+ * would be the same answer, and the emitter could not tell an ungranted sequence from an
509
+ * unmeasured one. `grantee IS NULL` marks the no-ACL row; a real PUBLIC grant has grantee 0,
510
+ * which `aclexplode` reports and the CASE turns into the literal 'PUBLIC'.
511
+ */
512
+ export const SEQUENCE_ACLS_SQL = `
513
+ SELECT
514
+ n.nspname || '.' || c.relname AS sequence,
515
+ tn.nspname || '.' || t.relname AS "table",
516
+ CASE WHEN a.grantee IS NULL THEN NULL ELSE COALESCE(r.rolname, 'PUBLIC') END AS grantee,
517
+ a.privilege_type AS privilege,
518
+ -- 'i' = an IDENTITY column's internal dependency, 'a' = a serial's auto one. Carried, not
519
+ -- just filtered on, because the two need OPPOSITE treatment from the inheritance rule.
520
+ (d.deptype = 'i') AS is_identity
521
+ FROM pg_class c
522
+ JOIN pg_namespace n ON n.oid = c.relnamespace
523
+ JOIN pg_depend d
524
+ ON d.classid = 'pg_class'::regclass AND d.objid = c.oid
525
+ AND d.deptype IN ('a', 'i') AND d.refobjsubid > 0
526
+ JOIN pg_class t ON t.oid = d.refobjid
527
+ JOIN pg_namespace tn ON tn.oid = t.relnamespace
528
+ LEFT JOIN LATERAL aclexplode(c.relacl) a ON a.grantee <> c.relowner
529
+ LEFT JOIN pg_roles r ON r.oid = a.grantee
530
+ WHERE c.relkind = 'S'
531
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
532
+ AND n.nspname NOT LIKE 'pg_%'
533
+ AND NOT EXISTS (
534
+ SELECT 1 FROM pg_depend e
535
+ WHERE e.objid = c.oid AND e.deptype = 'e'
536
+ )
537
+ ORDER BY 1, 3, 4;
538
+ `.trim();
539
+
540
+ export interface SequenceAclRow {
541
+ sequence: string;
542
+ table: string;
543
+ /** NULL when the sequence has no ACL at all — the row still exists. */
544
+ grantee: unknown;
545
+ privilege: unknown;
546
+ /** `d.deptype = 'i'` — the owning column is an identity one. Absent on an older read. */
547
+ is_identity?: unknown;
548
+ }
549
+
550
+ /** Fold the per-privilege rows into one entry per sequence, grants sorted for stability. */
551
+ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): SequenceGrantContract[] {
552
+ const bySequence = new Map<string, SequenceGrantContract>();
553
+ for (const row of rows) {
554
+ const entry = bySequence.get(row.sequence)
555
+ ?? {
556
+ sequence: row.sequence, table: row.table, grants: {} as Record<string, string[]>,
557
+ // Omitted rather than `false` on a serial sequence, so a contract read before this
558
+ // field existed and one read after compare structurally equal.
559
+ ...(truthy(row.is_identity) ? { identity: true } : {}),
560
+ };
561
+ if (row.grantee != null && row.privilege != null) {
562
+ const grantee = String(row.grantee);
563
+ entry.grants[grantee] = [...new Set([...(entry.grants[grantee] ?? []), String(row.privilege)])].sort();
564
+ }
565
+ bySequence.set(row.sequence, entry);
566
+ }
567
+ return [...bySequence.values()].sort((a, b) => a.sequence.localeCompare(b.sequence));
568
+ }
569
+
433
570
  /** The two privileges a schema ACL can carry, in aclitem letter form. */
434
571
  const SCHEMA_PRIV_LETTERS: Record<string, string> = { U: 'USAGE', C: 'CREATE' };
435
572
 
@@ -615,8 +752,10 @@ export interface ContractRows {
615
752
  columnGrants?: ColumnGrantRow[];
616
753
  /** pg_namespace ACL rows (SCHEMA_ACL_SQL) — optional; absent means schema ACLs unknown. */
617
754
  schemaAcls?: SchemaAclRow[];
755
+ /** SEQUENCE_ACLS_SQL. Optional — absent means not measured, never "no sequences". */
756
+ sequenceAcls?: SequenceAclRow[];
618
757
  /** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
619
- functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
758
+ functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean; ownerIsSuperuser?: boolean }[];
620
759
  }
621
760
 
622
761
  /**
@@ -673,6 +812,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
673
812
  securityDefiner: true,
674
813
  hasSearchPath: f.hasSearchPath,
675
814
  ...(f.ownerBypassesRls !== undefined ? { ownerBypassesRls: f.ownerBypassesRls } : {}),
815
+ ...(f.ownerIsSuperuser !== undefined ? { ownerIsSuperuser: f.ownerIsSuperuser } : {}),
816
+ ...(f.owner !== undefined ? { owner: f.owner } : {}),
676
817
  }))
677
818
  .sort((a, b) => a.name.localeCompare(b.name));
678
819
 
@@ -692,6 +833,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
692
833
  tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
693
834
  functions,
694
835
  ...(schemaAcls ? { schemaAcls } : {}),
836
+ // Same absent-means-unmeasured contract as schemaAcls above.
837
+ ...(rows.sequenceAcls ? { sequenceGrants: assembleSequenceGrants(rows.sequenceAcls) } : {}),
695
838
  };
696
839
  }
697
840
 
@@ -704,8 +847,8 @@ export async function introspectContract(
704
847
  // ONE session: the six queries describe one moment under one pinned search_path.
705
848
  // Policy USING / WITH CHECK expressions deparse relative to that path, so a read spread
706
849
  // across connections can report authz drift that does not exist.
707
- const [rls, policies, grants, columnGrants, schemaAcls, fnRows] = await session(
708
- [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, functionsSql],
850
+ const [rls, policies, grants, columnGrants, schemaAcls, sequenceAcls, fnRows] = await session(
851
+ [RLS_SQL, POLICIES_SQL, GRANTS_SQL, COLUMN_GRANTS_SQL, SCHEMA_ACL_SQL, SEQUENCE_ACLS_SQL, functionsSql],
709
852
  INTROSPECTION_SESSION,
710
853
  );
711
854
  return assembleContract({
@@ -714,6 +857,7 @@ export async function introspectContract(
714
857
  grants: grants as GrantRow[],
715
858
  columnGrants: columnGrants as ColumnGrantRow[],
716
859
  schemaAcls: schemaAcls as SchemaAclRow[],
860
+ sequenceAcls: sequenceAcls as SequenceAclRow[],
717
861
  functions: (fnRows as any[]).map(mapFunctionRow),
718
862
  });
719
863
  }
@@ -814,6 +958,14 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
814
958
  if (d.ownerBypassesRls !== undefined && l.ownerBypassesRls !== undefined && d.ownerBypassesRls !== l.ownerBypassesRls) {
815
959
  findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner bypasses RLS: declared ${d.ownerBypassesRls}, live ${l.ownerBypassesRls}` });
816
960
  }
961
+ // The name is recorded but NEVER compared — it legitimately differs between a dev
962
+ // machine, a deployed master and a normalized operator, and diffing it would false-fire
963
+ // on every environment. This boolean is the portable half, and it is the one that
964
+ // separates a deliberately-scoped BYPASSRLS operator from a superuser: both read `true`
965
+ // for ownerBypassesRls, and only one of them is a catastrophe.
966
+ if (d.ownerIsSuperuser !== undefined && l.ownerIsSuperuser !== undefined && d.ownerIsSuperuser !== l.ownerIsSuperuser) {
967
+ findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner is SUPERUSER: declared ${d.ownerIsSuperuser}, live ${l.ownerIsSuperuser}${l.ownerIsSuperuser ? ` (live owner '${l.owner ?? 'unknown'}') — a SECURITY DEFINER function owned by a superuser runs above RLS as that superuser on every call` : ''}` });
968
+ }
817
969
  }
818
970
  for (const name of lFns.keys()) {
819
971
  if (!dFns.has(name)) {
@@ -15,7 +15,7 @@
15
15
  * this layer reconciles without a migration file (unlike the data layer).
16
16
  */
17
17
 
18
- import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
18
+ import type { AuthzContract, TableContract, PolicyContract, SequenceGrantContract } from './authz-contract.js';
19
19
  import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
20
20
  import { matchPolicies } from './authz-identity.js';
21
21
  import { quoteQualified } from './pg-ident.js';
@@ -118,6 +118,58 @@ export function renderGrantExemptions(exemptions: readonly GrantExemption[]): st
118
118
  return lines;
119
119
  }
120
120
 
121
+ /** A grantee holding sequence privileges its table grants do not justify. */
122
+ export interface VestigialSequenceGrant {
123
+ grantee: string;
124
+ /** The sequences it holds privileges on while holding no INSERT on their tables. */
125
+ sequences: string[];
126
+ }
127
+
128
+ /**
129
+ * Sequence grants with no corresponding INSERT — an ADVISORY, never a revoke.
130
+ *
131
+ * The shape a brownfield database arrives in: a blanket `GRANT USAGE ON ALL SEQUENCES IN
132
+ * SCHEMA public` sitting beside a blanket `GRANT SELECT ON ALL TABLES`, years old, granted
133
+ * to a role that can INSERT into nothing. One consumer had exactly this — `authenticated`
134
+ * holding USAGE on all 27 sequences with INSERT on zero tables — and it was the single
135
+ * divergence between their database and the inheritance rule.
136
+ *
137
+ * Reported rather than revoked, and the distinction is the whole point: the inheritance rule
138
+ * only ever ADDS what an INSERT needs, so a vestigial grant is untouched by reconciliation and
139
+ * would otherwise sit there unnoticed forever. Naming it lets the owner decide. Revoking it
140
+ * would be the tool making a security decision from an inference — the same move we refused for
141
+ * the invented search_path.
142
+ */
143
+ export function vestigialSequenceGrants(live: AuthzContract): VestigialSequenceGrant[] {
144
+ if (!live.sequenceGrants) return []; // not measured
145
+ const insertsFor = new Map<string, Set<string>>();
146
+ for (const t of live.tables) {
147
+ for (const [grantee, privileges] of Object.entries(t.grants)) {
148
+ if (privileges.includes('INSERT')) {
149
+ (insertsFor.get(grantee) ?? insertsFor.set(grantee, new Set()).get(grantee)!).add(t.table);
150
+ }
151
+ }
152
+ }
153
+ const byGrantee = new Map<string, string[]>();
154
+ for (const seq of live.sequenceGrants) {
155
+ for (const grantee of Object.keys(seq.grants)) {
156
+ if (insertsFor.get(grantee)?.has(seq.table)) continue;
157
+ byGrantee.set(grantee, [...(byGrantee.get(grantee) ?? []), seq.sequence]);
158
+ }
159
+ }
160
+ return [...byGrantee.entries()]
161
+ .map(([grantee, sequences]) => ({ grantee, sequences: sequences.sort() }))
162
+ .sort((a, b) => a.grantee.localeCompare(b.grantee));
163
+ }
164
+
165
+ /** One line per grantee — the shared rendering, same idiom as renderGrantExemptions. */
166
+ export function renderVestigialSequenceGrants(findings: readonly VestigialSequenceGrant[]): string[] {
167
+ return findings.map(({ grantee, sequences }) =>
168
+ `${grantee} holds privileges on ${sequences.length} sequence(s) but can INSERT into none of their tables — `
169
+ + `probably a vestigial blanket GRANT ... ON ALL SEQUENCES. Nothing revokes it (the inheritance rule only adds); `
170
+ + `review and revoke by hand if it is dead: ${sequences.slice(0, 6).join(', ')}${sequences.length > 6 ? `, +${sequences.length - 6} more` : ''}.`);
171
+ }
172
+
121
173
  /** A USING/WITH CHECK clause expression, parenthesized exactly once. */
122
174
  const clause = parenthesizeOnce;
123
175
 
@@ -144,6 +196,91 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
144
196
  return new Map(c.tables.map((t) => [t.table, t]));
145
197
  }
146
198
 
199
+ /**
200
+ * A6 — the privileges a SERIAL sequence INHERITS from its table's declared grants.
201
+ *
202
+ * SERIAL only. An identity column's sequence is exempt and is skipped before this is
203
+ * consulted — see `reconcileSequenceGrants` for the measurement and why it matters.
204
+ *
205
+ * A role that may INSERT into a table but cannot draw its sequence value is not a state anyone
206
+ * declares deliberately; it is a state that happens when nobody carries the privilege across.
207
+ * Measured on a consumer's database: every INSERT into a serial-PK table failed
208
+ * `permission denied for sequence`, and two of their 563 tests were exactly that.
209
+ *
210
+ * The rule is INSERT on the table → USAGE + SELECT + UPDATE on its sequence, and all three
211
+ * matter. USAGE alone permits `nextval`, but `setval` needs UPDATE and reading `last_value`
212
+ * needs SELECT, so the common `rU` shape breaks the moment anything reseeds the counter.
213
+ *
214
+ * Signed off after measurement, not assumed: the consumer checked every role against every
215
+ * serial/identity sequence and found zero cases of INSERT-without-sequence-USAGE and zero of
216
+ * USAGE-without-UPDATE, so this under-grants nobody. Their one divergence — `authenticated`
217
+ * holding USAGE on all 27 sequences while able to INSERT into none — was a vestigial 2023
218
+ * blanket grant they are revoking, so their database converges to this rule rather than the
219
+ * reverse. The pull ADVISORY names that shape wherever it appears.
220
+ *
221
+ * Derived, never declared: there is no `sequence:` key in the model and nobody wants one.
222
+ */
223
+ export const SEQUENCE_PRIVILEGES_FOR_INSERT = ['SELECT', 'UPDATE', 'USAGE'] as const;
224
+
225
+ export function inheritedSequenceGrants(table: TableContract): Record<string, string[]> {
226
+ const out: Record<string, string[]> = {};
227
+ for (const [grantee, privileges] of Object.entries(table.grants)) {
228
+ if (privileges.includes('INSERT')) out[grantee] = [...SEQUENCE_PRIVILEGES_FOR_INSERT];
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Reconcile one table's SERIAL sequences against the grants they inherit. Identity sequences
235
+ * are read the same way but exempt from the rule — the skip below says why.
236
+ *
237
+ * Same per-grantee shape as `reconcileGrants`, and the same ungoverned-role exemption: a live
238
+ * grantee the declared authz does not govern is LEFT ALONE, because "no declaration" and "no
239
+ * privileges" are identical to a set difference, and treating them alike is what produced 32
240
+ * revokes against a brownfield adopter's migration role.
241
+ *
242
+ * The live side must have been READ for this to run at all. `sequenceGrants` absent means the
243
+ * recorder never looked, and planning revokes against a set nobody read is how a phantom
244
+ * statement gets minted on every run forever — a bug class that has already bitten this
245
+ * codebase twice.
246
+ */
247
+ function reconcileSequenceGrants(
248
+ d: TableContract,
249
+ liveSequences: readonly SequenceGrantContract[],
250
+ out: string[],
251
+ governed: ReadonlySet<string>,
252
+ ): void {
253
+ const declared = inheritedSequenceGrants(d);
254
+ for (const seq of liveSequences) {
255
+ // An IDENTITY sequence inherits NOTHING, and is neither granted nor revoked here.
256
+ //
257
+ // The A6 rule exists because a serial column's value comes from a DEFAULT `nextval(...)`
258
+ // that Postgres evaluates with the CALLER's privileges — no sequence grant, no INSERT. An
259
+ // identity column's value is supplied by the system as part of the column definition and
260
+ // consults no caller privilege at all. Measured: a role holding INSERT on the table and
261
+ // NOTHING on the sequence inserts fine into an identity table and fails on a serial one.
262
+ //
263
+ // Applying the rule here was a real WIDENING, caught by the round-trip oracle the day
264
+ // identity columns became declarable: the rebuild handed an INSERTing role SELECT + UPDATE
265
+ // + USAGE — direct `setval` control of a surrogate key — that the original database never
266
+ // granted and never needed. Nothing is revoked either: a grant somebody made deliberately
267
+ // is not ours to remove, and no declaration says it should not be there.
268
+ if (seq.identity) continue;
269
+ const target = quoteQualified(seq.sequence);
270
+ const grantees = new Set([...Object.keys(declared), ...Object.keys(seq.grants)]);
271
+ for (const grantee of [...grantees].sort()) {
272
+ if (!isGoverned(governed, grantee)) continue;
273
+ const want = new Set(declared[grantee] ?? []);
274
+ const have = new Set(seq.grants[grantee] ?? []);
275
+ const role = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
276
+ const toRevoke = [...have].filter((p) => !want.has(p)).sort();
277
+ const toGrant = [...want].filter((p) => !have.has(p)).sort();
278
+ if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON SEQUENCE ${target} FROM ${role};`);
279
+ if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON SEQUENCE ${target} TO ${role};`);
280
+ }
281
+ }
282
+ }
283
+
147
284
  /**
148
285
  * Emit the ordered SQL that transforms the live database into the declared
149
286
  * contract. Empty when they already match (the reconcile no-op). Order per table:
@@ -162,6 +299,12 @@ export function emitReconcileSql(
162
299
  // that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
163
300
  // it with the modules' declared governedRoles.
164
301
  const governed = opts.governedRoles ?? governedRoleSet(declared);
302
+ // Absent = the recorder never read sequences; skip the pass entirely rather than diff
303
+ // against a set nobody measured. Grouped once, not re-scanned per table.
304
+ const sequencesByTable = new Map<string, SequenceGrantContract[]>();
305
+ for (const seq of live.sequenceGrants ?? []) {
306
+ (sequencesByTable.get(seq.table) ?? sequencesByTable.set(seq.table, []).get(seq.table)!).push(seq);
307
+ }
165
308
 
166
309
  for (const d of declared.tables) {
167
310
  const l = lTables.get(d.table);
@@ -172,6 +315,8 @@ export function emitReconcileSql(
172
315
  reconcilePolicies(table, d, l, sql, governed);
173
316
  reconcileGrants(table, d, l, sql, governed);
174
317
  reconcileColumnGrants(table, d, l, sql, governed);
318
+ // A6: the sequences behind this table's SERIAL columns inherit its grants (identity ones do not).
319
+ reconcileSequenceGrants(d, sequencesByTable.get(d.table) ?? [], sql, governed);
175
320
  }
176
321
 
177
322
  return sql;
@@ -231,15 +231,37 @@ export function renderContractMarkdown(contract: AuthzContract): string {
231
231
  lines.push('');
232
232
  lines.push('These run with their owner\'s privileges, **above row security** — their own bodies enforce access. Review each one deliberately. A missing `search_path` is a hijack vector; an owner that bypasses RLS (superuser / BYPASSRLS) makes the function run above RLS as that role — the maximum blast radius. `db:migrate` often leaves functions superuser-owned; normalize ownership to a non-superuser operator.');
233
233
  lines.push('');
234
- const elevated = contract.functions.filter((f) => f.ownerBypassesRls);
234
+ // A correct design lights this whole report up: a deliberately-scoped operator is
235
+ // usually BYPASSRLS, so every SECDEF function it owns is "elevated". Splitting the
236
+ // superuser case out is what lets a reader tell correct from catastrophic at a glance —
237
+ // a consumer with 60 correctly-owned functions was reading the same warning they would
238
+ // have seen if all 60 had silently moved to a superuser.
239
+ // Three states, not two. `ownerIsSuperuser === undefined` means the contract predates
240
+ // the field — we do NOT know, and must not claim "non-superuser" for it.
241
+ const superuserOwned = contract.functions.filter((f) => f.ownerIsSuperuser === true);
242
+ const elevated = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === false);
243
+ const unknownOwner = contract.functions.filter((f) => f.ownerBypassesRls && f.ownerIsSuperuser === undefined);
244
+ if (unknownOwner.length) {
245
+ lines.push(`> **⚠ ${unknownOwner.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Re-run \`db:authz:pull\` to record whether the owner is a SUPERUSER, which is the difference between a scoped operator and the maximum blast radius.`);
246
+ lines.push('');
247
+ }
248
+ if (superuserOwned.length) {
249
+ lines.push(`> **🛑 ${superuserOwned.length} function(s) are owned by a SUPERUSER** — each runs above row security as that superuser on every call. This is the maximum blast radius and is almost never intended; it is what \`db:migrate\` and a models-only build both leave behind by default. Normalize ownership to a scoped operator.`);
250
+ lines.push('');
251
+ }
235
252
  if (elevated.length) {
236
- lines.push(`> **⚠ ${elevated.length} function(s) are owned by a role that bypasses RLS** they run above row security regardless of \`search_path\`. Normalize their ownership.`);
253
+ lines.push(`> **⚠ ${elevated.length} function(s) are owned by a non-superuser role that bypasses RLS.** If that role is your intended scoped operator, this is expected and the bodies are what enforce access. Confirm the owner below is the role you meant.`);
237
254
  lines.push('');
238
255
  }
239
256
  for (const f of contract.functions) {
240
257
  const sp = f.hasSearchPath ? 'search_path pinned ✓' : '**search_path NOT pinned ⚠**';
241
- const own = f.ownerBypassesRls ? ' · **owner bypasses RLS ⚠**' : '';
242
- lines.push(`- \`${f.name}\` ${sp}${own}`);
258
+ const owner = f.owner ? ` · owner \`${f.owner}\`` : '';
259
+ const own = f.ownerIsSuperuser === true
260
+ ? ' · **owned by SUPERUSER 🛑**'
261
+ : f.ownerBypassesRls
262
+ ? (f.ownerIsSuperuser === false ? ' · owner bypasses RLS' : ' · **owner bypasses RLS ⚠**')
263
+ : '';
264
+ lines.push(`- \`${f.name}\` — ${sp}${owner}${own}`);
243
265
  }
244
266
  lines.push('');
245
267
  }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `everystack db:build --database-url <url>` — build a database FROM the models, and KEEP it.
3
+ *
4
+ * db:build --database-url postgres://…/mydb [--models db/models]
5
+ *
6
+ * WHY THIS EXISTS. Deleting a migration folder is only safe once the models alone can rebuild
7
+ * an identical database — local dev, the test DB, a new stage. Every other verb failed that:
8
+ * `db:check` proved buildability on an ephemeral database and then DROPPED it, while
9
+ * `db:sync` / `db:generate --apply` run the DIFF builder, which has no bootstrap phase and
10
+ * cannot start from nothing. A consumer tested our own documentation sentence ("a fresh
11
+ * database is db:check's compose") and found no verb behind it. This is that verb.
12
+ *
13
+ * It is deliberately THIN: `buildIntoDatabase` is the same core `db:check`'s compose, the dev
14
+ * template and `createEphemeralDatabase` already run — extensions, schemas, contract roles,
15
+ * declared functions before the policies that call them, then state and the derived layer.
16
+ * A second implementation of that ordering would be a second thing to keep true.
17
+ *
18
+ * The venue is EXPLICIT (`--database-url` only, never the ambient env, same rule as db:swap
19
+ * and db:export): this command writes a whole schema, so the target must be named on the
20
+ * command line and never inherited from a shell that happens to point at production.
21
+ *
22
+ * It REFUSES a database that already holds declared objects. Building into an existing
23
+ * database is `db:sync`'s job (dev) or `db:plan`/`db:apply`'s (a stage); a verb that silently
24
+ * did both is how a populated database gets clobbered by a command whose name says "build".
25
+ */
26
+
27
+ import type { ModelDescriptor } from '@everystack/model';
28
+ import { buildIntoDatabase } from '../db-build.js';
29
+ import { createUrlRunner } from '../db-source.js';
30
+ import { resolveModelsPath } from '../models-path.js';
31
+ import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived.js';
32
+ import { loadModels } from './db-generate.js';
33
+ import { currentGitRef } from '../state-apply.js';
34
+ import { step, info, success, fail, warn } from '../output.js';
35
+
36
+ /** Tables already in the target, outside the schemas PostgreSQL owns. */
37
+ const OCCUPANCY_SQL = `
38
+ SELECT n.nspname || '.' || c.relname AS identity
39
+ FROM pg_class c
40
+ JOIN pg_namespace n ON n.oid = c.relnamespace
41
+ WHERE c.relkind IN ('r', 'v', 'm')
42
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
43
+ AND n.nspname NOT LIKE 'pg_%'
44
+ ORDER BY 1
45
+ LIMIT 20;
46
+ `.trim();
47
+
48
+ export async function dbBuildCommand(flags: Record<string, string>): Promise<void> {
49
+ // Flag-only venue. The env is never consulted: `db:build` writes a whole schema, and an
50
+ // ambient DATABASE_URL must not be able to choose which database that happens to.
51
+ const url = flags['database-url'];
52
+ if (!url || url === 'true') {
53
+ fail('db:build needs --database-url <url> — the venue is explicit by design (the ambient environment never picks the target for a command that writes a whole schema).');
54
+ process.exit(1);
55
+ }
56
+ if (flags.stage) {
57
+ fail('db:build has no --stage lane: building a fresh database is a local/dev operation. Evolve a deployed stage with db:plan → db:apply.');
58
+ process.exit(1);
59
+ }
60
+
61
+ const modelsPath = resolveModelsPath(flags.models);
62
+ let models: ModelDescriptor[];
63
+ try {
64
+ step(`Loading models from ${modelsPath}...`);
65
+ models = await loadModels(modelsPath);
66
+ info(`${models.length} model(s).`);
67
+ } catch (err: any) {
68
+ fail(err.message);
69
+ process.exit(1);
70
+ }
71
+
72
+ try {
73
+ if (flags['sql-dir']) {
74
+ fail(await retiredSqlDirFlagRefusal(flags['sql-dir']));
75
+ process.exit(1);
76
+ }
77
+ const retired = await retiredSqlDirAnywhere(flags.models);
78
+ if (retired) {
79
+ fail(retired);
80
+ process.exit(1);
81
+ }
82
+ } catch (err: any) {
83
+ fail(err.message);
84
+ process.exit(1);
85
+ }
86
+
87
+ // The modules carry what `models` alone cannot: the derived layer, standalone sequences,
88
+ // and the extensions whose types the columns are declared in.
89
+ let declaredDb: DeclaredDerived | null = null;
90
+ try {
91
+ declaredDb = await loadDeclaredDerived(flags.models);
92
+ } catch (err: any) {
93
+ fail(err.message);
94
+ process.exit(1);
95
+ }
96
+
97
+ // Refuse a target that already holds objects — see the header. Read on its own connection,
98
+ // closed before the build opens its own, so no session outlives the check it performed.
99
+ try {
100
+ const { runner, end } = await createUrlRunner(url);
101
+ let occupied: string[];
102
+ try {
103
+ occupied = (await runner(OCCUPANCY_SQL)).map((r: any) => String(r.identity));
104
+ } finally {
105
+ await end?.();
106
+ }
107
+ if (occupied.length > 0) {
108
+ fail(
109
+ `db:build refuses a database that already holds objects — it found ${occupied.length}: ${occupied.slice(0, 8).join(', ')}${occupied.length > 8 ? ', …' : ''}.\n`
110
+ + ` This verb builds a FRESH database from the models. To evolve an existing one: db:sync (a dev database) or db:plan → db:apply (a stage).`,
111
+ );
112
+ process.exit(1);
113
+ }
114
+ } catch (err: any) {
115
+ fail(`Could not read the target: ${err.message}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ step('Building the declared state (extensions → schemas → roles → functions → state → derived)...');
120
+ let built;
121
+ try {
122
+ built = await buildIntoDatabase(url, models, {
123
+ declared: declaredDb?.objects,
124
+ sequences: declaredDb?.sequences,
125
+ extensions: declaredDb?.extensions,
126
+ actor: process.env.USER ?? null,
127
+ gitRef: currentGitRef(),
128
+ });
129
+ } catch (err: any) {
130
+ fail(`Build failed: ${err.message}`);
131
+ process.exit(1);
132
+ }
133
+
134
+ for (const line of built.report) info(line);
135
+ if (built.createdRoles.length) {
136
+ info(`Created ${built.createdRoles.length} contract role(s) (NOLOGIN, cluster-level): ${built.createdRoles.join(', ')}.`);
137
+ }
138
+
139
+ // The bar is the same one db:check reports, and it is stated as a fact or not at all:
140
+ // a database that did not land on the models' fingerprint is not the declared state,
141
+ // however few statements were left over.
142
+ if (!built.converged || !built.fingerprintMatch) {
143
+ fail(
144
+ `The database was built but does NOT match the declared state (fingerprint ${built.fingerprint.slice(0, 12)}). `
145
+ + `It is left in place for inspection — run db:generate --dry-run against it to see what differs.`,
146
+ );
147
+ process.exit(1);
148
+ }
149
+
150
+ success(`Built and kept — the database IS the declared state at ${built.fingerprint.slice(0, 12)}.`);
151
+ warn('Roles are cluster-level: a role this build created is visible to every database in the cluster.');
152
+ info('Verify independently: everystack db:fingerprint --database-url <url> (expect MATCH), db:reconcile --check.');
153
+ }