@everystack/cli 0.4.102 → 0.4.104

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 (38) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-compile.ts +48 -11
  4. package/src/cli/authz-contract.ts +6 -0
  5. package/src/cli/authz-derive.ts +3 -1
  6. package/src/cli/commands/db-apply.ts +34 -10
  7. package/src/cli/commands/db-backup.ts +62 -26
  8. package/src/cli/commands/db-fork.ts +2 -1
  9. package/src/cli/commands/db-plan.ts +5 -0
  10. package/src/cli/commands/db-pull.ts +48 -12
  11. package/src/cli/commands/db-reconcile.ts +34 -5
  12. package/src/cli/commands/db-sync.ts +32 -3
  13. package/src/cli/commands/db.ts +46 -21
  14. package/src/cli/db-build.ts +11 -1
  15. package/src/cli/declared-diff.ts +3 -2
  16. package/src/cli/derived-apply.ts +5 -1
  17. package/src/cli/derived-catchup.ts +4 -0
  18. package/src/cli/derived-compile.ts +2 -1
  19. package/src/cli/derived-introspect.ts +22 -1
  20. package/src/cli/derived-plan.ts +64 -0
  21. package/src/cli/derived-render.ts +1 -0
  22. package/src/cli/edge-plan.ts +35 -0
  23. package/src/cli/index-lock.ts +65 -0
  24. package/src/cli/index.ts +1 -1
  25. package/src/cli/lifecycle-canon.ts +2 -1
  26. package/src/cli/lifecycle-derived.ts +334 -45
  27. package/src/cli/lifecycle-preflight.ts +498 -0
  28. package/src/cli/lifecycle-pull.ts +119 -11
  29. package/src/cli/migration-compile.ts +4 -3
  30. package/src/cli/migration-generate.ts +3 -2
  31. package/src/cli/model-api.ts +9 -0
  32. package/src/cli/model-render.ts +80 -8
  33. package/src/cli/plain-output.ts +14 -1
  34. package/src/cli/schema-compile.ts +34 -67
  35. package/src/cli/schema-fingerprint.ts +3 -2
  36. package/src/cli/schema-introspect.ts +33 -2
  37. package/src/cli/state-function-deps.ts +2 -1
  38. package/src/cli/lifecycle-verify.ts +0 -232
@@ -0,0 +1,498 @@
1
+ /** Read-only prerequisite for the state and derived halves of a lifecycle apply. */
2
+ import { createHash } from 'node:crypto';
3
+ import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
4
+ import type { QueryRunner } from './authz-contract';
5
+ import { compileTableContract } from './authz-compile';
6
+ import { parseQualified, type SourceObject } from './derived-source';
7
+ import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
8
+
9
+ export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
10
+ export const LIFECYCLE_LOG_OUTDATED_REFUSAL = 'everystack.destroy_log is out of date (it has no before column or no marker functions); run db:provision before db:apply';
11
+ /**
12
+ * Present, and current: it has the `before` column and the three marker functions the generated
13
+ * destroy calls (destroy_log_mark, destroy_log_unmark, destroy_cutoff). The reconcile creates
14
+ * functions with check_function_bodies off, so on an earlier log it would install them and every
15
+ * lifecycle DELETE would then fail with an unknown-column or unknown-function error.
16
+ */
17
+ export const LIFECYCLE_LOG_SQL = `SELECT pg_catalog.to_regclass('everystack.destroy_log') IS NOT NULL AS present,
18
+ EXISTS (SELECT 1 FROM pg_catalog.pg_attribute WHERE attrelid = pg_catalog.to_regclass('everystack.destroy_log')
19
+ AND attname = 'before' AND NOT attisdropped)
20
+ AND pg_catalog.to_regprocedure('everystack.destroy_log_mark(timestamptz)') IS NOT NULL
21
+ AND pg_catalog.to_regprocedure('everystack.destroy_log_unmark()') IS NOT NULL
22
+ AND pg_catalog.to_regprocedure('everystack.destroy_cutoff()') IS NOT NULL AS current`;
23
+
24
+ /** The refusal for one LIFECYCLE_LOG_SQL row, or null when the log is present and current. */
25
+ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unknown } | undefined): string | null {
26
+ if (log?.present !== true) return LIFECYCLE_LOG_REFUSAL;
27
+ return log.current === true ? null : LIFECYCLE_LOG_OUTDATED_REFUSAL;
28
+ }
29
+
30
+ /**
31
+ * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
32
+ * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
33
+ */
34
+ export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
35
+ if (!models.some(model => model.lifecycle === true)) return null;
36
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
37
+ // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
38
+ // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
39
+ const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
40
+ return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
41
+ }
42
+
43
+ const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
44
+ const ident = (s: string): string => (/^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`);
45
+ /** A `schema.table` label, quoted for a fix statement. */
46
+ const quoteLabel = (label: string): string => {
47
+ const dot = label.indexOf('.');
48
+ return `${ident(label.slice(0, dot))}.${ident(label.slice(dot + 1))}`;
49
+ };
50
+ const textArray = (values: readonly string[]): string => `ARRAY[${values.map(literal).join(', ')}]::text[]`;
51
+ /** An aclexplode grantee by name, PUBLIC for 0. */
52
+ const granteeName = (column: string): string =>
53
+ `CASE WHEN ${column} = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(${column})::text END`;
54
+ const md5 = (text: string): string => createHash('md5').update(text, 'utf8').digest('hex');
55
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
56
+ const textList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v.startsWith('{')
57
+ ? v.slice(1, -1).split(',').filter(Boolean).map(s => s.replace(/^"|"$/g, '')) : []);
58
+ const MARKERS = Object.keys(DESTROY_LOG_MARKER_BODIES) as Array<keyof typeof DESTROY_LOG_MARKER_BODIES>;
59
+ const MARKER_CONFIG = `search_path=${DESTROY_LOG_MARKER_SEARCH_PATH}`;
60
+
61
+ /**
62
+ * What the catalog check reads from the declared side. The check needs nothing else from the
63
+ * models, so db:reconcile's ops action can run it from its wire payload, which carries no models.
64
+ *
65
+ * lifecycleTables `schema.table` of every lifecycle table
66
+ * applicationRoles every role the models' compiled contracts grant to or name in a policy. A
67
+ * request runs as one of them. SQL cannot tell which role is the application's
68
+ * login; a login with no grant or policy of its own is application-side only
69
+ * when it can assume one of these. PUBLIC and the lifecycle role never count.
70
+ */
71
+ export interface LifecycleCatalogInputs {
72
+ lifecycleTables: readonly string[];
73
+ applicationRoles: readonly string[];
74
+ /** Every declared dependent key (dependentKeysFromDeclared). */
75
+ dependentKeys: readonly LifecycleDependentKey[];
76
+ /**
77
+ * `schema.table` of each lifecycle table whose manifest has no nullify record (an older CLI sent
78
+ * it): a key it declares 'nullify' cannot be seen, so its finding says to upgrade the CLI.
79
+ */
80
+ nullifyUnrecorded?: readonly string[];
81
+ /**
82
+ * Before the plan only (lifecycleApplyRefusal): `schema.table` of every modeled table, whose keys
83
+ * rule 6 leaves to the plan. Absent after the state apply: every key is checked.
84
+ */
85
+ modeledTables?: readonly string[];
86
+ }
87
+
88
+ /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
89
+ export interface LifecycleDependentKey {
90
+ child: string;
91
+ column: string;
92
+ parent: string;
93
+ }
94
+
95
+ /**
96
+ * The declared dependents ('cascade', 'restrict' and 'nullify'), read from the generated lifecycle
97
+ * functions' manifests: the declared layer itself, so the models path and a db:reconcile payload
98
+ * (which carries no models) read one source.
99
+ */
100
+ export function dependentKeysFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): LifecycleDependentKey[] {
101
+ const label = (name: string): string => {
102
+ if (!name.startsWith('"')) return name;
103
+ const { schema, name: table } = parseQualified(name);
104
+ return `${schema}.${table}`;
105
+ };
106
+ const keys = new Map<string, LifecycleDependentKey>();
107
+ for (const object of declared) {
108
+ const manifest = lifecycleManifestOf(object);
109
+ if (!manifest) continue;
110
+ const edges = [...manifest.decl.edges, ...(manifest.decl.nullify ?? []).map(n => ({ ...n, parent: manifest.model }))];
111
+ for (const edge of edges) {
112
+ const key = { child: label(edge.child), column: edge.column, parent: edge.parent };
113
+ keys.set(JSON.stringify(key), key);
114
+ }
115
+ }
116
+ return [...keys.values()].sort((a, b) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1));
117
+ }
118
+
119
+ /** The lifecycle tables whose transition manifest has no `decl.nullify` (a CLI that predates it sent the layer). */
120
+ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<typeof lifecycleManifestOf>[0][]): string[] {
121
+ return [...new Set(declared.map(object => lifecycleManifestOf(object))
122
+ .filter(manifest => manifest?.role === 'transition' && manifest.decl.nullify === undefined)
123
+ .map(manifest => manifest!.model))].sort();
124
+ }
125
+
126
+ const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
127
+
128
+ /** The catalog check's inputs, read from the models. */
129
+ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
130
+ const roles = new Set<string>();
131
+ for (const model of models) {
132
+ const contract = compileTableContract(model);
133
+ for (const role of Object.keys(contract.grants)) roles.add(role);
134
+ for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
135
+ for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
136
+ }
137
+ return {
138
+ lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
139
+ applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
140
+ dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
141
+ };
142
+ }
143
+
144
+ /**
145
+ * `x` can act as `y`: SET ROLE to it, or inherit its privileges. On PostgreSQL 16 and later
146
+ * pg_has_role's MEMBER also counts an ADMIN-only grant (no SET, no INHERIT), which a non-superuser
147
+ * CREATEROLE role gets on every role it creates; SET and USAGE do not. Before 16 every membership
148
+ * could SET ROLE, MEMBER means exactly that, and 'SET' is not a keyword, so the branch is on
149
+ * server_version_num.
150
+ */
151
+ const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
152
+ WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
153
+ ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
154
+ /** `x` can act as `y`, or hold ADMIN on it: an ADMIN holder can grant `y` to itself WITH SET. */
155
+ const reaches = (x: string, y: string): string =>
156
+ `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
157
+
158
+ /**
159
+ * The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
160
+ * as, an application role. A superuser passes every pg_has_role and is left out; so is the RDS
161
+ * master, whose ADMIN-only membership in the roles it created lets it act as none of them.
162
+ */
163
+ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::text AS rolname FROM pg_catalog.pg_roles r
164
+ WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
165
+ WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
166
+
167
+ /** Rule 1, attributes. */
168
+ const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
169
+ COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
170
+ COALESCE(lc.rolcreaterole, false) AS createrole, COALESCE(lc.rolbypassrls, false) AS bypassrls
171
+ FROM (SELECT 1) AS one LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}`;
172
+
173
+ /**
174
+ * Rule 1, members: each application-side role that reaches the lifecycle role, with the
175
+ * application roles it acts as. The operator's membership (WITH INHERIT FALSE, SET TRUE, which a
176
+ * non-superuser builder needs to create the lifecycle functions) passes: it acts as no application role.
177
+ */
178
+ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
179
+ SELECT s.rolname AS role, ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
180
+ WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = s.oid OR ${assumes('s.oid', 'a.oid')}) ORDER BY 1) AS apps
181
+ FROM side s JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
182
+ WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
183
+ ORDER BY 1`;
184
+
185
+ /**
186
+ * Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
187
+ * write the marker; the owner of a marker function can replace it. Each such object is a finding
188
+ * when its owner is the lifecycle role, or when an application-side role is that owner, can act
189
+ * as it, or holds ADMIN on it. The operator owning all of them is the documented setup and passes.
190
+ */
191
+ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
192
+ owned AS (
193
+ SELECT 'table' AS kind, n.nspname::text || '.' || c.relname::text AS name, c.relowner AS owner
194
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
195
+ WHERE c.relkind IN ('r', 'p') AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
196
+ UNION ALL
197
+ SELECT 'table', 'everystack.destroy_log', c.relowner FROM pg_catalog.pg_class c
198
+ WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
199
+ UNION ALL
200
+ SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
201
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig))
202
+ SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
203
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
204
+ FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
205
+ WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
206
+ ORDER BY 2, 1`;
207
+
208
+ /**
209
+ * Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
210
+ * columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
211
+ * (table or column) held by anyone but its owner: a writer could forge or remove a marker.
212
+ */
213
+ const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
214
+ a.privilege_type AS privilege, NULL::text AS "column"
215
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
216
+ CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
217
+ WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
218
+ ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
219
+ OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
220
+ UNION ALL
221
+ SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
222
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
223
+ JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
224
+ CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
225
+ WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
226
+ ((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
227
+ OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
228
+ ORDER BY 1, 4 NULLS FIRST, 3, 2`;
229
+
230
+ /**
231
+ * Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
232
+ * lifecycle role and nobody else but the owner (PUBLIC included; a NULL ACL is PUBLIC's default),
233
+ * and the language, settings and body (md5 of prosrc) provisioning installs.
234
+ */
235
+ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS present, COALESCE(p.prosecdef, false) AS secdef,
236
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(d.relowner)::text AS log_owner,
237
+ pg_catalog.md5(p.prosrc) AS body_md5, l.lanname::text AS language,
238
+ pg_catalog.array_to_string(p.proconfig, '; ') AS config,
239
+ ARRAY(SELECT DISTINCT ${granteeName('a.grantee')} FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
240
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner AND a.grantee IS DISTINCT FROM lc.oid ORDER BY 1) AS others,
241
+ EXISTS (SELECT 1 FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
242
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee = lc.oid) AS lifecycle_execute
243
+ FROM (VALUES ${MARKERS.map((sig, i) => `(${i}, ${literal(sig)})`).join(', ')}) AS s(i, sig)
244
+ LEFT JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(s.sig)
245
+ LEFT JOIN pg_catalog.pg_language l ON l.oid = p.prolang
246
+ LEFT JOIN pg_catalog.pg_class d ON d.oid = pg_catalog.to_regclass('everystack.destroy_log')
247
+ LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
248
+ ORDER BY s.i`;
249
+
250
+ /**
251
+ * Rule 5. A trigger on a lifecycle table whose function is SECURITY DEFINER and owned by neither
252
+ * the table's owner nor the lifecycle role: it runs as that third role inside every delete.
253
+ */
254
+ const definerTriggerSql = (tables: string[], lc: string): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
255
+ pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
256
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(c.relowner)::text AS table_owner
257
+ FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
258
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
259
+ JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
260
+ WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
261
+ AND p.prosecdef AND p.proowner <> c.relowner
262
+ AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
263
+ ORDER BY 1, 2`;
264
+
265
+ /**
266
+ * Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
267
+ * in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
268
+ */
269
+ const inboundKeySql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS child, k.conname::text AS "constraint",
270
+ ARRAY(SELECT a.attname::text FROM pg_catalog.unnest(k.conkey) WITH ORDINALITY AS u(attnum, i)
271
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.attnum ORDER BY u.i) AS columns,
272
+ pn.nspname::text || '.' || p.relname::text AS parent, k.confdeltype::text AS action
273
+ FROM pg_catalog.pg_constraint k JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
274
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
275
+ JOIN pg_catalog.pg_class p ON p.oid = k.confrelid JOIN pg_catalog.pg_namespace pn ON pn.oid = p.relnamespace
276
+ WHERE k.contype = 'f' AND k.conparentid = 0 AND (pn.nspname::text || '.' || p.relname::text) = ANY(${textArray(tables)})
277
+ ORDER BY 1, 2`;
278
+
279
+ const ON_DELETE: Record<string, string> = { a: 'NO ACTION', r: 'RESTRICT', c: 'CASCADE', n: 'SET NULL', d: 'SET DEFAULT' };
280
+
281
+ const OWNER_HAZARD = {
282
+ table: 'an owner can disable its triggers',
283
+ log: 'an owner can write the marker',
284
+ function: 'an owner can replace the function',
285
+ };
286
+
287
+ /**
288
+ * The live catalog facts a lifecycle soft delete rests on, which the generator cannot guarantee.
289
+ * Every finding is named on its own line with its fix, or null when there is none.
290
+ * `lifecycleRole` is a test seam (the role is cluster-level); callers leave it unset.
291
+ *
292
+ * THREAT MODEL. This defends an HONEST operator from installing or re-arming the lifecycle
293
+ * SECURITY DEFINER objects onto a database whose grants or ownership have drifted: an application
294
+ * role that owns a lifecycle table, holds TRIGGER on one, can write everystack.destroy_log, a
295
+ * replaced marker function, and the rest below. It does not defend against a hostile IAM deployer,
296
+ * who can already reach the ops Lambda's other actions; so the roles may come from the caller
297
+ * (db:reconcile's wire payload carries them).
298
+ *
299
+ * The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
300
+ * (inputs from the models); db:reconcile, local and in the ops Lambda, through
301
+ * lifecycleReconcileRefusal (tables from the declared transition triggers).
302
+ */
303
+ export async function lifecycleCatalogRefusal(
304
+ runner: QueryRunner,
305
+ inputs: LifecycleCatalogInputs,
306
+ options: { lifecycleRole?: string } = {},
307
+ ): Promise<string | null> {
308
+ const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
309
+ const tables = [...new Set(inputs.lifecycleTables)].sort();
310
+ if (tables.length === 0) return null;
311
+ const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
312
+ const findings: string[] = [];
313
+
314
+ const [role] = await runner(roleSql(lc));
315
+ if (!truthy(role?.present)) {
316
+ findings.push(`role ${lc} is missing. Fix: run db:provision.`);
317
+ } else {
318
+ const attributes = (['login', 'super', 'createrole', 'bypassrls'] as const).filter(key => truthy(role[key]));
319
+ const word = { login: 'LOGIN', super: 'SUPERUSER', createrole: 'CREATEROLE', bypassrls: 'BYPASSRLS' };
320
+ if (attributes.length > 0) {
321
+ findings.push(`role ${lc} has ${attributes.map(key => word[key]).join(', ')}. `
322
+ + `Fix: ALTER ROLE ${ident(lc)} ${attributes.map(key => `NO${word[key]}`).join(' ')};`);
323
+ }
324
+ for (const member of await runner(memberSql(apps, lc))) {
325
+ findings.push(`role ${member.role} can assume ${lc} and is, or can assume, an application role (${textList(member.apps).join(', ')}). `
326
+ + `Fix: REVOKE ${ident(lc)} FROM ${ident(String(member.role))}; (or from the role it reaches ${lc} through).`);
327
+ }
328
+ }
329
+
330
+ for (const row of await runner(ownerSql(tables, apps, lc))) {
331
+ const kind = row.kind === 'function' ? 'function' : 'table';
332
+ const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log : OWNER_HAZARD[kind];
333
+ const via = textList(row.via);
334
+ const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
335
+ : via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
336
+ : `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
337
+ const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
338
+ const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
339
+ ? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
340
+ : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
341
+ findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}`);
342
+ }
343
+
344
+ for (const row of await runner(aclSql(tables))) {
345
+ const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
346
+ const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
347
+ findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. `
348
+ + `Fix: REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`);
349
+ }
350
+
351
+ for (const row of await runner(markerSql(lc))) {
352
+ const sig = String(row.sig) as keyof typeof DESTROY_LOG_MARKER_BODIES;
353
+ const fn = `marker function ${sig}`;
354
+ if (!truthy(row.present)) { findings.push(`${fn} is missing. Fix: run db:provision.`); continue; }
355
+ if (!truthy(row.secdef)) findings.push(`${fn} is not SECURITY DEFINER. Fix: run db:provision.`);
356
+ if (row.owner !== row.log_owner) {
357
+ findings.push(`${fn} is owned by ${row.owner}, not ${row.log_owner} (the owner of everystack.destroy_log). `
358
+ + `Fix: ALTER FUNCTION ${sig} OWNER TO ${ident(String(row.log_owner))}; then run db:provision.`);
359
+ }
360
+ const others = textList(row.others);
361
+ if (others.length > 0) findings.push(`${fn} grants EXECUTE to ${others.join(', ')}. Fix: run db:provision.`);
362
+ if (!truthy(row.lifecycle_execute)) findings.push(`${fn} does not grant EXECUTE to ${lc}. Fix: run db:provision.`);
363
+ if (row.language !== DESTROY_LOG_MARKER_LANGUAGES[sig]) {
364
+ findings.push(`${fn} is in language ${row.language}, not ${DESTROY_LOG_MARKER_LANGUAGES[sig]}. Fix: run db:provision.`);
365
+ }
366
+ if ((row.config ?? '') !== MARKER_CONFIG) {
367
+ findings.push(`${fn} has settings ${row.config || '(none)'}, not ${MARKER_CONFIG}. Fix: run db:provision.`);
368
+ }
369
+ if (row.body_md5 !== md5(DESTROY_LOG_MARKER_BODIES[sig])) {
370
+ findings.push(`${fn} has a body other than the one db:provision installs. Fix: run db:provision.`);
371
+ }
372
+ }
373
+
374
+ for (const row of await runner(definerTriggerSql(tables, lc))) {
375
+ findings.push(`trigger ${row.trigger} on ${row.table} runs ${row.function}, SECURITY DEFINER and owned by ${row.owner}, `
376
+ + `neither the table's owner (${row.table_owner}) nor ${lc}; it runs as ${row.owner} inside every delete. `
377
+ + `Fix: ALTER FUNCTION ${row.function} SECURITY INVOKER; or ALTER FUNCTION ${row.function} OWNER TO ${ident(String(row.table_owner))};`);
378
+ }
379
+
380
+ const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
381
+ const modeled = new Set(inputs.modeledTables ?? []);
382
+ // After the state apply a key the models no longer hold, or now declare, may not be converged yet.
383
+ const applyFirst = inputs.modeledTables ? '' : 'If the models already refer to it by value or declare it, mint a plan that drops or rewrites the key '
384
+ + '(db:plan, with --allow-drops when it is a drop) and db:apply it. Otherwise ';
385
+ const unrecorded = new Set(inputs.nullifyUnrecorded ?? []);
386
+ for (const row of await runner(inboundKeySql(tables))) {
387
+ // Before the plan, a modeled table's keys are the plan's to converge: the models hold only
388
+ // declared keys from lifecycle tables (or refuse to compile), and the plan drops the rest.
389
+ if (modeled.has(String(row.child))) continue;
390
+ const columns = textList(row.columns);
391
+ const key = `foreign key ${row.constraint} on ${row.child} (${columns.join(', ')}) references lifecycle table ${row.parent} `
392
+ + `with ON DELETE ${ON_DELETE[String(row.action)] ?? row.action}`;
393
+ const drop = `ALTER TABLE ${quoteLabel(String(row.child))} DROP CONSTRAINT ${ident(String(row.constraint))};`;
394
+ if (!tables.includes(String(row.child))) {
395
+ findings.push(`${key}, but ${row.child} is not a lifecycle table: only a lifecycle table may hold a foreign key into a lifecycle table. `
396
+ + `Fix: ${applyFirst}${applyFirst ? 'make' : 'Make'} ${row.child} a lifecycle model and declare the dependent on ${row.parent}, or refer to it by value and drop the key: ${drop}`);
397
+ continue;
398
+ }
399
+ if (columns.length === 1 && declared.has(JSON.stringify([row.child, columns[0], row.parent]))) continue;
400
+ findings.push(`${key}, and no model declares what deleting a ${row.parent} row does to it: soft delete never consults it, `
401
+ + `so destroy would defer or skip the row. Fix: ${applyFirst}${applyFirst ? 'declare' : 'Declare'} it as a hasMany dependent on ${row.parent}, or drop the key: ${drop}`
402
+ + (unrecorded.has(String(row.parent)) ? ` The declared layer for ${row.parent} carries no nullify record, so the sending CLI may predate it: `
403
+ + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
404
+ }
405
+
406
+ if (findings.length === 0) return null;
407
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
408
+ + `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
409
+ }
410
+
411
+ /** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
412
+ export const LIFECYCLE_ROLES_ABSENT_REFUSAL = 'this db:reconcile payload carries lifecycle-owned objects but no application roles, '
413
+ + 'so the lifecycle catalog check cannot run. The sending CLI predates the check: upgrade the CLI (@everystack/cli) and re-run.';
414
+
415
+ /** The declared dependents are read from the transition functions' manifests: one without fails closed. */
416
+ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]): string => `this db:reconcile payload's lifecycle transition `
417
+ + `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
418
+ + 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
419
+
420
+ /** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
421
+ const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
422
+
423
+ /**
424
+ * The lifecycle tables, read from the declared derived layer: each `<t>_lifecycle_transition`
425
+ * trigger names its table. Every lifecycle-owned object must belong to one of those tables by its
426
+ * exact generated name in the table's schema (lifecycleRole, or a retired role). Never by prefix:
427
+ * every name of a table `t_lifecycle_x` starts with `t_lifecycle_`, so a prefix would hand them to
428
+ * `t`. One that belongs to none is a contradiction, refused: the check would otherwise run on fewer
429
+ * tables than the objects it arms. Never read from the live catalog, which would be a second
430
+ * derivation that drifts.
431
+ */
432
+ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]): { tables: string[] } | { refusal: string } {
433
+ const tables = [...new Set(declared.flatMap(object => {
434
+ if (object.kind !== 'trigger' || !object.table) return [];
435
+ const { schema, name } = parseQualified(object.table);
436
+ return object.name === `${name}_lifecycle_transition` ? [`${schema}.${name}`] : [];
437
+ }))].sort();
438
+ const orphans = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE
439
+ && !tables.some(table => table.startsWith(`${object.schema}.`) && (lifecycleRole(`${object.schema}.${object.name}`, table) !== undefined
440
+ || RETIRED_LIFECYCLE_ROLES.some(role => `${object.schema}.${object.name}` === `${table}_lifecycle_${role}`))))
441
+ .map(object => object.identity).sort();
442
+ if (orphans.length === 0) return { tables };
443
+ return {
444
+ refusal: `the declared derived layer contradicts itself: ${orphans.join(', ')} ${orphans.length === 1 ? 'is' : 'are'} owned by `
445
+ + `${LIFECYCLE_OWNER_ROLE}, but no declared <table>_lifecycle_transition trigger names ${orphans.length === 1 ? 'its table' : 'their tables'}. `
446
+ + 'The lifecycle tables are read from those triggers, so the catalog check cannot cover these objects. '
447
+ + 'Compile the declared layer from the models with this CLI and re-run.',
448
+ };
449
+ }
450
+
451
+ /**
452
+ * db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
453
+ * declared layer's own consistency, the application roles' presence (fail closed), then the
454
+ * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles` is `unknown`
455
+ * because the ops Lambda reads it off the wire.
456
+ */
457
+ export async function lifecycleReconcileRefusal(
458
+ runner: QueryRunner,
459
+ declared: readonly SourceObject[],
460
+ options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
461
+ ): Promise<string | null> {
462
+ if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
463
+ const [log] = await runner(LIFECYCLE_LOG_SQL);
464
+ const shape = lifecycleLogRowRefusal(log);
465
+ if (shape) return shape;
466
+ const derived = lifecycleTablesFromDeclared(declared);
467
+ if ('refusal' in derived) return derived.refusal;
468
+ const roles = options.applicationRoles;
469
+ if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
470
+ const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
471
+ && !lifecycleManifestOf(object)).map(object => object.identity).sort();
472
+ if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
473
+ return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
474
+ nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
475
+ { lifecycleRole: options.lifecycleRole });
476
+ }
477
+
478
+ // Live = fires in an ordinary session ('O' origin/local, 'A' always; not 'R' replica-only or 'D')
479
+ // and runs the table's own generated transition function, not merely a trigger with its name.
480
+ const LIVE_TRANSITIONS_SQL = `SELECT n.nspname || '.' || c.relname AS "table" FROM pg_catalog.pg_trigger g
481
+ JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
482
+ JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
483
+ WHERE NOT g.tgisinternal AND g.tgenabled IN ('O', 'A') AND g.tgname = c.relname || '_lifecycle_transition'
484
+ AND p.pronamespace = c.relnamespace AND p.proname = c.relname || '_lifecycle_transition'`;
485
+
486
+ /**
487
+ * --state-only skips the derived layer, so a lifecycle table whose transition trigger is not live
488
+ * would keep its DELETE policies with nothing turning a DELETE into a soft delete.
489
+ */
490
+ export async function lifecycleStateOnlyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
491
+ const lifecycle = models.filter(model => model.lifecycle === true);
492
+ if (lifecycle.length === 0) return null;
493
+ const live = new Set((await runner(LIVE_TRANSITIONS_SQL)).map(row => row.table));
494
+ const missing = lifecycle.map(model => `${model.schema}.${model.table}`).filter(table => !live.has(table)).sort();
495
+ if (missing.length === 0) return null;
496
+ return `--state-only skips the lifecycle triggers, and ${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no live transition trigger, `
497
+ + 'so a DELETE there would remove rows for real. Run db:apply without --state-only.';
498
+ }