@everystack/server 0.4.28 → 0.4.30

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to `@everystack/server` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.30] - 2026-09-04
8
+
9
+ ### Fixed
10
+
11
+ - ADOPT resolves its master from ADMIN_DATABASE_URL on a BYO/external RDS
12
+
13
+ ## [0.4.29] - 2026-09-04
14
+
15
+ ### Added
16
+
17
+ - ENACT core for brownfield ownership — PG16 guard + owner-grant router
18
+ - wire ENACT into runProvision + master-connection dep (B2, B3, BV)
19
+ - ADOPT semantic audit + confirm gate (B4a, B4c core)
20
+ - ADOPT reassignment planner + pre-flight (B4 core)
21
+ - wire ADOPT into runProvision — master-only, confirm-gated, one transaction (B4, B4c)
22
+ - wire the master connection into the db:provision ops action (B6, server half)
23
+
24
+ ### Fixed
25
+
26
+ - BLOCKER A — reorder ownership convergence AFTER the secret write
27
+ - BLOCKER B — master SELF-GRANTS SET on the declared owner (RDS split now works)
28
+ - BLOCKER C — the confirm gate reads LIVE facts, not declared
29
+ - round-2 BLOCKER-1 — run ownership convergence on the credentials no-op path too
30
+
7
31
  ## [0.4.28] - 2026-09-02
8
32
 
9
33
  ### Added
@@ -21,6 +45,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
21
45
 
22
46
  - delete the orphaned recompile-descent helpers, rewire the illusion of coverage (F1)
23
47
 
48
+ ## [0.4.27] - 2026-08-22
49
+
50
+ ### Added
51
+
52
+ - auto-generated changelogs from conventional commits
53
+
54
+ ### Fixed
55
+
56
+ - db:provision is additive-only — never reset an existing role's password
57
+ - address closing-gate review — pin ordering, guard half-done recovery
58
+ - address Fable red-team — rotated-stage guard, safe polarity
59
+
24
60
  ## [0.4.26] - 2026-08-15
25
61
 
26
62
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -187,8 +187,8 @@
187
187
  "sst": "4.13.1",
188
188
  "ts-jest": "29.4.9",
189
189
  "typescript": "5.9.3",
190
- "@everystack/cli": "0.4.70",
191
- "@everystack/auth": "0.4.4"
190
+ "@everystack/auth": "0.4.4",
191
+ "@everystack/cli": "0.4.71"
192
192
  },
193
193
  "scripts": {
194
194
  "test": "jest",
package/src/db.ts CHANGED
@@ -150,6 +150,26 @@ export function getOperatorDatabaseUrl(): { url: string; source: 'admin' | 'mast
150
150
  return null;
151
151
  }
152
152
 
153
+ /**
154
+ * The connection ADOPT (ownership reassignment) runs on: the linked SST Postgres master when one is
155
+ * present, else the `ADMIN_DATABASE_URL` operator secret.
156
+ *
157
+ * A brownfield / external-RDS app — the exact `db:pull --abilities live` audience — has NO linked
158
+ * `sst.aws.Postgres` component. It wires the database as secrets, and its `ADMIN_DATABASE_URL` is a
159
+ * privileged operator (commonly BYPASSRLS and a member of `postgres`, i.e. master-capable) that the
160
+ * ops Lambda already carries. Resolving ADOPT's master ONLY through the component dead-ended those
161
+ * apps on the missing component. This falls back to the admin secret so ADOPT has a connection to
162
+ * try. Whether that connection can ACTUALLY reassign is NOT decided here — ADOPT's live
163
+ * `master_can_act` probe decides it: a least-privilege admin resolves but the probe then refuses
164
+ * precisely (naming the grant to run), while a master-capable admin proceeds.
165
+ *
166
+ * `master` (linked component) is preferred because its presence guarantees master capability; the
167
+ * admin fallback is probe-gated. Never the least-privilege `DATABASE_URL`.
168
+ */
169
+ export function getAdoptMasterUrl(): string | null {
170
+ return getMasterDatabaseUrl() ?? getAdminDatabaseUrl();
171
+ }
172
+
153
173
  export function getJwtSecret(): Uint8Array {
154
174
  // PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
155
175
  const pascal = sstLink('JwtSecret')?.value as string | undefined;
@@ -0,0 +1,307 @@
1
+ /**
2
+ * owner-adoption — the ADOPT half of brownfield ownership convergence (Gap 2).
3
+ *
4
+ * A brownfield database's functions/views were created by the old tool AS the RDS master
5
+ * (`postgres`), not as the operator or the declared owner. The least-privilege operator cannot
6
+ * `CREATE OR REPLACE` an object it does not own, so db:reconcile refuses "must be owner". ADOPT
7
+ * reassigns each object the Models DECLARE to its declared owner — but this must run on the MASTER
8
+ * connection (PostgreSQL 16 `ALTER ... OWNER TO` needs the runner to be a member of the CURRENT
9
+ * owner, and giving the operator INHERIT membership in `postgres` would make it the master — the
10
+ * one thing the whole least-privilege design forbids).
11
+ *
12
+ * ADOPT IS A RUNTIME BEHAVIOR CHANGE, NOT COSMETIC HARDENING. A SECURITY DEFINER function owned by
13
+ * `postgres` executes with the master's rights; reassigning its owner flips the runs-as identity
14
+ * AT CALL TIME (not at ALTER time — the ALTER is always green). A view's underlying-table checks
15
+ * run as its owner. So ADOPT is gated (`--confirm` + a printed manifest) for exactly the objects
16
+ * whose semantics change, and the pre-adopt SEMANTIC AUDIT below flags where a capability is lost.
17
+ *
18
+ * THE AUDIT IS HEURISTIC BY NECESSITY, and says so. PostgreSQL does not track which tables an
19
+ * opaque function body (a SQL/plpgsql source string) references, so "check every referenced table"
20
+ * is not precisely realizable. What IS precise is the ATTRIBUTE DELTA — a superuser/BYPASSRLS
21
+ * current owner handing to an owner that is neither is a real, certain capability loss. The body
22
+ * regex and the schema-wide privilege gap are WARNINGS, not guarantees. The audit never claims an
23
+ * object is safe; it names what it can prove and warns where it cannot.
24
+ *
25
+ * This module is pure. runProvision wires the live catalog reads (role attributes, the privilege
26
+ * gap) and applies the reassignment in one transaction on the master.
27
+ */
28
+
29
+ /** One object to (possibly) reassign. `body` is the function source, absent for views/matviews. */
30
+ export interface Adoptee {
31
+ /** Reassign target: `api.foo(integer)` for a function, `api.v_report` for a view/matview. */
32
+ identity: string;
33
+ schema: string;
34
+ name: string;
35
+ kind: 'function' | 'view' | 'materialized view';
36
+ /** SECURITY DEFINER — functions only; always false for a view/matview. */
37
+ secdef: boolean;
38
+ /** The live owner (e.g. `postgres`). */
39
+ currentOwner: string;
40
+ /** The owner the Models declare. */
41
+ declaredOwner: string;
42
+ body?: string;
43
+ }
44
+
45
+ /** The cluster attributes of a role that decide what its owned objects can do. */
46
+ export interface RoleAttrs {
47
+ superuser: boolean;
48
+ bypassRls: boolean;
49
+ }
50
+
51
+ export interface AdoptionFinding {
52
+ /**
53
+ * capability-drop — PRECISE: a superuser current owner hands to a non-superuser; superuser-only
54
+ * body operations will fail at call time.
55
+ * rls-filtering — PRECISE: the runs-as identity loses RLS bypass; cross-owner reads may silently
56
+ * return filtered rows.
57
+ * superuser-op — WARNING: the body matches a superuser-only operation (regex, may false-positive).
58
+ * privilege-gap — WARNING: the declared owner lacks privileges on some app tables; an adopted
59
+ * object that touches one fails at call time (which tables it touches is not knowable here).
60
+ */
61
+ severity: 'capability-drop' | 'rls-filtering' | 'superuser-op' | 'privilege-gap';
62
+ identity: string;
63
+ detail: string;
64
+ }
65
+
66
+ /**
67
+ * Body operations that need superuser and will fail as a non-superuser owner. A WARNING signal —
68
+ * a regex over the source, so it can match text in a comment or string. Never a guarantee; it
69
+ * points a human at a body to read, which is the audit Fable calls the real work.
70
+ */
71
+ const SUPERUSER_OP_RE =
72
+ /\b(pg_terminate_backend|pg_read_file|pg_read_binary_file|pg_ls_dir|pg_stat_file|lo_import|lo_export|COPY\s+.*\s+FROM\s+PROGRAM|CREATE\s+ROLE|CREATE\s+USER|ALTER\s+SYSTEM)\b/i;
73
+
74
+ /** Only objects whose owner actually changes are adopted; the rest are already correct. */
75
+ export function needsReassignment(a: Adoptee): boolean {
76
+ return a.currentOwner !== a.declaredOwner;
77
+ }
78
+
79
+ /**
80
+ * The pre-adopt semantic audit. `attrs` carries the cluster attributes of every current and
81
+ * declared owner named. `tableGaps[owner]` lists app tables that owner cannot access at all — the
82
+ * informational "where a SECDEF body could fail" set, or undefined when not probed.
83
+ *
84
+ * Findings are ordered precise-first so a manifest leads with what is certain.
85
+ */
86
+ export function auditAdoption(
87
+ adoptees: Adoptee[],
88
+ attrs: Record<string, RoleAttrs>,
89
+ tableGaps: Record<string, string[]> = {},
90
+ ): AdoptionFinding[] {
91
+ const findings: AdoptionFinding[] = [];
92
+ for (const a of adoptees) {
93
+ if (!needsReassignment(a)) continue;
94
+ // A view/matview runs its underlying-table checks as its owner; a SECDEF function runs its
95
+ // whole body as its owner. An INVOKER function runs as its caller, so its owner change carries
96
+ // no runtime semantics — it is reassigned, but not audited for capability loss.
97
+ const runsAsOwner = a.kind !== 'function' || a.secdef;
98
+ if (!runsAsOwner) continue;
99
+
100
+ const cur = attrs[a.currentOwner];
101
+ const decl = attrs[a.declaredOwner];
102
+
103
+ if (cur?.superuser && !decl?.superuser) {
104
+ findings.push({ severity: 'capability-drop', identity: a.identity,
105
+ detail: `runs-as flips from superuser '${a.currentOwner}' to '${a.declaredOwner}' — any superuser-only operation in the body fails at CALL time (the ALTER is green regardless).` });
106
+ }
107
+ if ((cur?.superuser || cur?.bypassRls) && !decl?.bypassRls) {
108
+ findings.push({ severity: 'rls-filtering', identity: a.identity,
109
+ detail: `loses RLS bypass ('${a.currentOwner}' had it, '${a.declaredOwner}' does not) — reads across owners may now return FILTERED rows silently, not an error.` });
110
+ }
111
+ if (a.body && SUPERUSER_OP_RE.test(a.body)) {
112
+ const m = a.body.match(SUPERUSER_OP_RE);
113
+ findings.push({ severity: 'superuser-op', identity: a.identity,
114
+ detail: `body references '${m?.[0]}', which needs superuser — review whether '${a.declaredOwner}' can run it (heuristic: this is a text match, verify by reading the body).` });
115
+ }
116
+ const gaps = tableGaps[a.declaredOwner];
117
+ if (gaps?.length) {
118
+ findings.push({ severity: 'privilege-gap', identity: a.identity,
119
+ detail: `'${a.declaredOwner}' lacks privileges on ${gaps.length} app table(s) (e.g. ${gaps.slice(0, 3).join(', ')}) — if this object touches one it fails at call time (which tables it touches is not knowable from the catalog).` });
120
+ }
121
+ }
122
+ return findings;
123
+ }
124
+
125
+ /** One object that will actually reassign, with the LIVE facts the confirm gate keys on. */
126
+ export interface Reassignment {
127
+ kind: Adoptee['kind'];
128
+ /** SECURITY DEFINER as the model DECLARES it. */
129
+ declaredSecdef: boolean;
130
+ /** SECURITY DEFINER as the object is RIGHT NOW (probed). A live-SECDEF/declared-INVOKER function
131
+ * still runs as its owner until db:reconcile replaces the body — so the gate must see this. */
132
+ liveSecdef: boolean;
133
+ declaredOwner: string;
134
+ }
135
+
136
+ /**
137
+ * Whether ADOPT requires `--confirm`, keyed on LIVE facts (Fable BLOCKER C — the declared attribute
138
+ * is not enough). Confirm when a reassignment changes the runs-as identity or hands ownership to a
139
+ * powerful role:
140
+ * - a view or matview (its underlying-table checks run as the owner), OR
141
+ * - a function that is SECURITY DEFINER either as declared OR as it lives right now, OR
142
+ * - any object whose declared owner is LIVE superuser / BYPASSRLS (handing ownership escalates).
143
+ * A plain invoker function to an ordinary owner needs no confirm. Empty → false.
144
+ */
145
+ export function requiresConfirm(reassignments: Reassignment[], attrs: Record<string, RoleAttrs> = {}): boolean {
146
+ return reassignments.some((r) => {
147
+ if (r.kind !== 'function') return true;
148
+ if (r.declaredSecdef || r.liveSecdef) return true;
149
+ const target = attrs[r.declaredOwner];
150
+ return !!(target?.superuser || target?.bypassRls);
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Live catalog facts about one candidate object, read on the MASTER before any ALTER — the
156
+ * pre-flight that turns a mid-batch Postgres error into a precise refusal.
157
+ */
158
+ export interface AdopteeProbe {
159
+ identity: string;
160
+ /** The object is present in the catalog. Absent → nothing to adopt (reconcile will create it). */
161
+ exists: boolean;
162
+ /** prokind/relkind matches the declared kind. `ALTER VIEW` on a matview ERRORS — refuse first. */
163
+ kindMatches: boolean;
164
+ /** The LIVE SECURITY DEFINER flag (pg_proc.prosecdef; false for views/matviews). The gate keys on
165
+ * this AND the declared flag — a live-SECDEF function declared INVOKER still flips runs-as now. */
166
+ liveSecdef: boolean;
167
+ /** The object's real current owner (trusted over the declared guess). */
168
+ liveOwner: string;
169
+ /** An extension owns this object (pg_depend deptype='e'). Its OID binds to the extension — never reassign. */
170
+ isExtensionMember: boolean;
171
+ /** The declared owner role exists (ADOPT never invents one). */
172
+ declaredOwnerExists: boolean;
173
+ /**
174
+ * PG16 `ALTER … OWNER TO` needs the runner (the master) to hold the privileges of the CURRENT
175
+ * owner (to give the object away) AND be able to SET ROLE to the DECLARED owner (to receive it).
176
+ * These are the two conjuncts, read separately so a refusal names the RIGHT one — and because they
177
+ * fail for different, differently-fixable reasons on a non-superuser RDS master.
178
+ */
179
+ masterPrivsOfCurrent: boolean;
180
+ masterCanSetDeclared: boolean;
181
+ /**
182
+ * The master holds ADMIN OPTION on the declared owner (or is a superuser) — so when it cannot yet
183
+ * SET ROLE to it, ADOPT can self-grant `GRANT <declaredOwner> TO CURRENT_USER WITH SET TRUE` inside
184
+ * its own transaction rather than refuse. ENACT grants the declared owner to the OPERATOR, never
185
+ * the master, so on a genuine RDS split this self-grant is what makes the master able to adopt.
186
+ */
187
+ masterAdminOfDeclared: boolean;
188
+ }
189
+
190
+ const OWNER_IDENT = /^[a-z_][a-z0-9_]*$/;
191
+ const KEYWORD: Record<Adoptee['kind'], string> = {
192
+ function: 'FUNCTION', view: 'VIEW', 'materialized view': 'MATERIALIZED VIEW',
193
+ };
194
+ const RELKIND: Record<Adoptee['kind'], string> = { function: 'f', view: 'v', 'materialized view': 'm' };
195
+
196
+ /** A SQL single-quoted literal, doubling embedded quotes. */
197
+ function lit(value: string): string {
198
+ return `'${value.replace(/'/g, "''")}'`;
199
+ }
200
+
201
+ /**
202
+ * The live pre-flight probe, read on the MASTER. One row per adoptee via UNION ALL. Uses
203
+ * `to_regprocedure`/`to_regclass`, which return NULL (never an error) for an absent object, so the
204
+ * probe reports `exists=false` rather than throwing mid-batch.
205
+ *
206
+ * The two PG16 `ALTER … OWNER TO` conjuncts are read SEPARATELY (privs of the current owner; SET on
207
+ * the declared owner), plus whether the master holds ADMIN on the declared owner so ADOPT can
208
+ * self-grant the missing SET. A REAL superuser satisfies all three. On RDS nobody is a real
209
+ * superuser, so memberships decide it — read from the catalog, never assumed (Fable). The owner's
210
+ * CREATE on the schema is ENACT's precondition (ENACT runs before ADOPT), not re-checked here.
211
+ */
212
+ export function adopteeProbeSql(adoptees: Adoptee[]): string {
213
+ const selects = adoptees.map((a) => {
214
+ const isFn = a.kind === 'function';
215
+ // `o` is the catalog row; `reg` resolves the OID without erroring on absence.
216
+ const regExpr = isFn ? `to_regprocedure(${lit(a.identity)})` : `to_regclass(${lit(a.identity)})`;
217
+ const catalog = isFn ? 'pg_proc' : 'pg_class';
218
+ const ownerCol = isFn ? 'o.proowner' : 'o.relowner';
219
+ const kindCol = isFn ? `(o.prokind = 'f')` : `(o.relkind = '${RELKIND[a.kind]}')`;
220
+ const superExpr = `(SELECT b.rolsuper FROM pg_roles b WHERE b.rolname = CURRENT_USER)`;
221
+ const decl = lit(a.declaredOwner);
222
+ return `
223
+ SELECT
224
+ ${lit(a.identity)} AS identity,
225
+ (o.oid IS NOT NULL) AS exists,
226
+ COALESCE(${kindCol}, false) AS kind_matches,
227
+ COALESCE(${isFn ? 'o.prosecdef' : 'false'}, false) AS live_secdef,
228
+ COALESCE(pg_get_userbyid(${ownerCol}), '') AS live_owner,
229
+ COALESCE((SELECT true FROM pg_depend d WHERE d.objid = o.oid AND d.deptype = 'e' LIMIT 1), false) AS is_extension_member,
230
+ EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${decl}) AS declared_owner_exists,
231
+ COALESCE(${superExpr} OR pg_has_role(CURRENT_USER, ${ownerCol}, 'USAGE'), false) AS master_privs_of_current,
232
+ COALESCE(${superExpr} OR pg_has_role(CURRENT_USER, ${decl}, 'SET'), false) AS master_can_set_declared,
233
+ COALESCE(${superExpr} OR (SELECT m.admin_option FROM pg_auth_members m JOIN pg_roles r2 ON r2.oid = m.roleid
234
+ WHERE r2.rolname = ${decl} AND m.member = (SELECT oid FROM pg_roles WHERE rolname = CURRENT_USER)), false) AS master_admin_of_declared
235
+ FROM (SELECT ${regExpr} AS oid) reg
236
+ LEFT JOIN ${catalog} o ON o.oid = reg.oid`.trim();
237
+ });
238
+ return selects.join('\nUNION ALL\n');
239
+ }
240
+
241
+ export interface AdoptionPlan {
242
+ /** `ALTER ... OWNER TO ...` statements, applied on the master in ONE transaction. */
243
+ statements: string[];
244
+ /** The adoptees each statement moves, in order — for the manifest and the schema_log record. */
245
+ reassigned: Adoptee[];
246
+ /** Precise pre-flight refusals — nothing is applied if this is non-empty (all or nothing). */
247
+ refusals: string[];
248
+ }
249
+
250
+ /**
251
+ * Turn declared objects + their live probes into the reassignment plan. Only objects that EXIST
252
+ * and are mis-owned are moved; a declared object that is absent is left for reconcile to create
253
+ * owned correctly. Every object is checked — exists, kind, not-extension, declared owner present,
254
+ * master can act — BEFORE any statement, so the caller applies the whole set or refuses the whole
255
+ * set. A refusal names the exact obstacle; it is never a raw error from the middle of an ALTER.
256
+ */
257
+ export function planAdoptionReassignment(adoptees: Adoptee[], probes: AdopteeProbe[]): AdoptionPlan {
258
+ const byId = new Map(probes.map((p) => [p.identity, p]));
259
+ const alters: string[] = [];
260
+ const selfGrants = new Set<string>(); // one per declared owner the master must self-grant SET on
261
+ const plan: AdoptionPlan = { statements: [], reassigned: [], refusals: [] };
262
+
263
+ for (const a of adoptees) {
264
+ const p = byId.get(a.identity);
265
+ if (!p || !p.exists) continue; // not in the DB — reconcile creates it owned correctly, nothing to adopt
266
+ if (p.liveOwner === a.declaredOwner) continue; // already correct
267
+ if (p.isExtensionMember) {
268
+ plan.refusals.push(`${a.identity}: owned by an extension — never reassigned (its OID binds to the extension).`);
269
+ continue;
270
+ }
271
+ if (!p.kindMatches) {
272
+ plan.refusals.push(`${a.identity}: declared as ${a.kind} but the live object is a different kind — refusing before ALTER ${KEYWORD[a.kind]} errors on it.`);
273
+ continue;
274
+ }
275
+ if (!p.declaredOwnerExists) {
276
+ plan.refusals.push(`${a.identity}: declared owner '${a.declaredOwner}' does not exist — ADOPT never invents a role. CREATE ROLE ${a.declaredOwner} NOLOGIN; then re-run.`);
277
+ continue;
278
+ }
279
+ if (!OWNER_IDENT.test(a.declaredOwner)) {
280
+ plan.refusals.push(`${a.identity}: invalid declared owner name ${JSON.stringify(a.declaredOwner)}.`);
281
+ continue;
282
+ }
283
+ // PG16 ALTER … OWNER TO needs BOTH conjuncts. Name the failing one precisely (the two fail for
284
+ // different reasons and have different fixes), and try to self-heal the SET conjunct.
285
+ if (!p.masterPrivsOfCurrent) {
286
+ plan.refusals.push(`${a.identity}: the master lacks the privileges of the current owner '${p.liveOwner}' — it cannot give the object away. As a role that can: GRANT ${p.liveOwner} TO <master>; then re-run.`);
287
+ continue;
288
+ }
289
+ if (!p.masterCanSetDeclared) {
290
+ // ENACT grants the declared owner to the OPERATOR, not the master — so on the real RDS split
291
+ // the master arrives here without SET on the declared owner. If it holds ADMIN on that owner
292
+ // it self-grants SET inside the transaction; otherwise it is a precise refusal naming THIS
293
+ // conjunct (not the current-owner one), with the fix.
294
+ if (p.masterAdminOfDeclared) {
295
+ selfGrants.add(`GRANT ${a.declaredOwner} TO CURRENT_USER WITH SET TRUE, INHERIT FALSE`);
296
+ } else {
297
+ plan.refusals.push(`${a.identity}: the master cannot SET ROLE to the declared owner '${a.declaredOwner}' and lacks ADMIN OPTION on it to grant itself. As a role with admin on '${a.declaredOwner}': GRANT ${a.declaredOwner} TO <master> WITH SET TRUE; then re-run.`);
298
+ continue;
299
+ }
300
+ }
301
+ alters.push(`ALTER ${KEYWORD[a.kind]} ${a.identity} OWNER TO ${a.declaredOwner}`);
302
+ plan.reassigned.push({ ...a, currentOwner: p.liveOwner });
303
+ }
304
+ // Self-grants first (they make the SET conjunct true), then the reassignments — one transaction.
305
+ plan.statements = [...selfGrants, ...alters];
306
+ return plan;
307
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * owner-enactment — the ENACT half of brownfield ownership convergence (Gap 1).
3
+ *
4
+ * The reconciler (db:reconcile) runs its DDL as the least-privilege operator (`migrator`),
5
+ * creating each function/view AS its declared owner under `SET ROLE`. That needs two grants the
6
+ * operator does not hold on a fresh or brownfield database, and which db:reconcile can only
7
+ * REFUSE with (ownerPreflightRefusal prints the fix SQL, nothing applies it):
8
+ *
9
+ * 1. `GRANT <owner> TO <operator> WITH INHERIT FALSE, SET TRUE` — so the operator may assume
10
+ * the owner. INHERIT FALSE is deliberate: the operator becomes the owner only under an
11
+ * explicit SET ROLE, never inheriting the owner's privileges ambiently.
12
+ * 2. `GRANT CREATE ON SCHEMA <schema> TO <owner>` — so the CREATE, which runs AS the owner,
13
+ * can place the object.
14
+ *
15
+ * These are privileged, idempotent SETUP — the same category as the role chain — so they belong
16
+ * in db:provision, not in the least-privilege reconcile lane (which structurally cannot run them).
17
+ * This module is the PURE core: a probe (read) and a router that turns probe rows into the exact
18
+ * statements, each routed to the connection that can run it — the operator's own connection where
19
+ * it holds the grant right, the master where it does not, and a REFUSAL carrying runnable SQL
20
+ * where neither can. Nothing here opens a connection; runProvision wires the two live probes.
21
+ *
22
+ * IDEMPOTENCE is by construction, not by DO-guards: a statement is emitted ONLY when its guard is
23
+ * unmet (the operator cannot yet SET ROLE the owner; the owner cannot yet CREATE in the schema).
24
+ * A re-run, where both are satisfied, emits nothing.
25
+ *
26
+ * Owner ROLES are never invented — an absent owner is refused with `CREATE ROLE ... NOLOGIN`, the
27
+ * same rule the reconcile preflight holds: a security principal minted by a tool arrives with no
28
+ * memberships, no grants, and no review.
29
+ */
30
+
31
+ /** PostgreSQL 16 — the first version with `GRANT ... WITH INHERIT FALSE, SET TRUE` and role `SET` membership. */
32
+ export const MIN_SERVER_VERSION_NUM = 160000;
33
+
34
+ /**
35
+ * The version refusal, or null when the server is new enough.
36
+ *
37
+ * Below 16 the `WITH INHERIT FALSE, SET TRUE` grammar and `pg_has_role(..., 'SET')` do not exist;
38
+ * the nearest equivalent is a global `NOINHERIT` on the operator, which changes EVERY other
39
+ * inherited grant it holds — a design fork, not a syntax swap. Provision refuses rather than
40
+ * silently taking a different, wider action. `server_version_num` is Postgres's own integer form
41
+ * (e.g. 160004 for 16.4).
42
+ */
43
+ export function pgVersionRefusal(serverVersionNum: number): string | null {
44
+ if (serverVersionNum >= MIN_SERVER_VERSION_NUM) return null;
45
+ const major = Math.floor(serverVersionNum / 10000);
46
+ return (
47
+ `owner enactment needs PostgreSQL >= 16 (this server reports ${major}). ` +
48
+ `The 'WITH INHERIT FALSE, SET TRUE' membership grant and role-level SET privilege are 16+; ` +
49
+ `below 16 the equivalent is a global NOINHERIT on the operator, which changes every other ` +
50
+ `inherited grant it holds — a different action provision will not take silently.`
51
+ );
52
+ }
53
+
54
+ /** One declared owner and the schema(s) its objects live in — the enactment input. */
55
+ export interface DeclaredOwner {
56
+ owner: string;
57
+ schema: string;
58
+ }
59
+
60
+ /**
61
+ * The connection-independent facts about one (owner, schema), read once on either connection.
62
+ * "Can the operator already assume the owner?" and "can the owner already create in the schema?"
63
+ * are properties of the catalog, not of who is asking.
64
+ */
65
+ export interface OwnerFactRow {
66
+ owner: string;
67
+ schema: string;
68
+ /** The owner role exists in the cluster. Absent → refuse, never invent. */
69
+ roleExists: boolean;
70
+ /** The schema exists. A CREATE grant on an absent schema cannot be written yet. */
71
+ schemaExists: boolean;
72
+ /** The operator can already `SET ROLE <owner>` — no membership grant needed. */
73
+ canSetRole: boolean;
74
+ /** The owner already holds `CREATE` on the schema — no create grant needed. */
75
+ ownerCanCreate: boolean;
76
+ }
77
+
78
+ /**
79
+ * What ONE connection can grant, per (owner, schema) — read on that connection with CURRENT_USER.
80
+ * Whether this connection can `GRANT <owner> TO <operator>` (it holds ADMIN OPTION on the owner,
81
+ * or is a superuser) and whether it can `GRANT CREATE ON SCHEMA <schema> TO <owner>` (it owns the
82
+ * schema or holds CREATE WITH GRANT OPTION, or is a superuser).
83
+ */
84
+ export interface GranterCapabilityRow {
85
+ owner: string;
86
+ schema: string;
87
+ canGrantMembership: boolean;
88
+ canGrantCreate: boolean;
89
+ }
90
+
91
+ /** The routed enactment: statements the operator runs, statements the master must run, and refusals. */
92
+ export interface EnactmentPlan {
93
+ /** Runnable on the operator's own connection. */
94
+ asOperator: string[];
95
+ /** Must run on the master connection (the operator lacks the grant right). */
96
+ asMaster: string[];
97
+ /** Neither connection can enact this — each line is a human-readable refusal carrying the fix SQL. */
98
+ refusals: string[];
99
+ }
100
+
101
+ const IDENT = /^[a-z_][a-z0-9_]*$/;
102
+ function assertIdent(name: string, what: string): void {
103
+ if (!IDENT.test(name)) throw new Error(`Invalid ${what}: ${JSON.stringify(name)} — must match ${IDENT}`);
104
+ }
105
+
106
+ /**
107
+ * The connection-independent probe: for each (owner, schema), does the owner exist, does the
108
+ * schema exist, can `operator` already SET ROLE the owner, and does the owner already hold CREATE.
109
+ * `has_schema_privilege` throws on an absent schema, so the schema is LEFT JOINed and the privilege
110
+ * is evaluated only when the schema is really there. Run once, on either connection.
111
+ */
112
+ export function ownerFactsSql(owners: DeclaredOwner[], operator: string): string {
113
+ assertIdent(operator, 'operator');
114
+ const pairs = owners.map((o) => {
115
+ assertIdent(o.owner, 'owner');
116
+ assertIdent(o.schema, 'schema');
117
+ return `('${o.owner}', '${o.schema}')`;
118
+ });
119
+ return `
120
+ WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
121
+ SELECT
122
+ d.owner_name AS owner,
123
+ d.schema_name AS schema,
124
+ (r.oid IS NOT NULL) AS role_exists,
125
+ (n.oid IS NOT NULL) AS schema_exists,
126
+ CASE WHEN r.oid IS NULL THEN false ELSE pg_has_role('${operator}', r.oid, 'SET') END AS can_set_role,
127
+ CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN false
128
+ ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
129
+ FROM declared d
130
+ LEFT JOIN pg_roles r ON r.rolname = d.owner_name
131
+ LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
132
+ ORDER BY d.owner_name, d.schema_name
133
+ `.trim();
134
+ }
135
+
136
+ /**
137
+ * The connection-DEPENDENT probe: run on a connection to learn what CURRENT_USER can grant.
138
+ * Can it `GRANT <owner> TO <operator>` — it holds ADMIN OPTION on the owner (pg_auth_members), or
139
+ * is a superuser (RDS: a member of rds_superuser). Can it `GRANT CREATE ON SCHEMA <schema>` — it
140
+ * owns the schema, holds CREATE WITH GRANT OPTION, or is a superuser. Run on the operator's
141
+ * connection and, when present, on the master's; the router merges the two.
142
+ */
143
+ export function granterCapabilitySql(owners: DeclaredOwner[]): string {
144
+ const pairs = owners.map((o) => {
145
+ assertIdent(o.owner, 'owner');
146
+ assertIdent(o.schema, 'schema');
147
+ return `('${o.owner}', '${o.schema}')`;
148
+ });
149
+ return `
150
+ WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')}),
151
+ me AS (
152
+ SELECT b.oid, (b.rolsuper OR EXISTS (
153
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
154
+ WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
155
+ )) AS is_super
156
+ FROM pg_roles b WHERE b.rolname = CURRENT_USER
157
+ )
158
+ SELECT
159
+ d.owner_name AS owner,
160
+ d.schema_name AS schema,
161
+ (me.is_super OR EXISTS (
162
+ SELECT 1 FROM pg_auth_members m
163
+ WHERE m.roleid = r.oid AND m.member = me.oid AND m.admin_option
164
+ )) AS can_grant_membership,
165
+ (me.is_super OR n.nspowner = me.oid
166
+ OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION')) AS can_grant_create
167
+ FROM declared d
168
+ CROSS JOIN me
169
+ LEFT JOIN pg_roles r ON r.rolname = d.owner_name
170
+ LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
171
+ ORDER BY d.owner_name, d.schema_name
172
+ `.trim();
173
+ }
174
+
175
+ const keyOf = (owner: string, schema: string) => `${owner}${schema}`;
176
+
177
+ /**
178
+ * Route the two grants each (owner, schema) may need onto the connection that can run them.
179
+ *
180
+ * `operator` is the least-privilege role (`migrator`). `masterCaps` is null when no master
181
+ * connection is available — then anything the operator cannot do is a refusal, never an
182
+ * unverified attempt on a connection that may not exist. Fable's rule: precise pre-flight
183
+ * refusal beats a mid-batch failure, and the master's capability is PROBED, never assumed.
184
+ */
185
+ export function planOwnerEnactment(
186
+ operator: string,
187
+ facts: OwnerFactRow[],
188
+ operatorCaps: GranterCapabilityRow[],
189
+ masterCaps: GranterCapabilityRow[] | null,
190
+ ): EnactmentPlan {
191
+ const opCap = new Map(operatorCaps.map((c) => [keyOf(c.owner, c.schema), c]));
192
+ const mCap = masterCaps ? new Map(masterCaps.map((c) => [keyOf(c.owner, c.schema), c])) : null;
193
+ const plan: EnactmentPlan = { asOperator: [], asMaster: [], refusals: [] };
194
+
195
+ for (const f of facts) {
196
+ if (!f.roleExists) {
197
+ plan.refusals.push(
198
+ `owner role '${f.owner}' does not exist — provision never invents a security principal. ` +
199
+ `Create it, then re-run: CREATE ROLE ${f.owner} NOLOGIN;`,
200
+ );
201
+ continue;
202
+ }
203
+ const op = opCap.get(keyOf(f.owner, f.schema));
204
+ const master = mCap?.get(keyOf(f.owner, f.schema)) ?? null;
205
+
206
+ // 1. Membership: the operator must be able to SET ROLE the owner.
207
+ if (!f.canSetRole) {
208
+ const stmt = `GRANT ${f.owner} TO ${operator} WITH INHERIT FALSE, SET TRUE`;
209
+ if (op?.canGrantMembership) plan.asOperator.push(stmt);
210
+ else if (master?.canGrantMembership) plan.asMaster.push(stmt);
211
+ else plan.refusals.push(
212
+ `cannot GRANT ${f.owner} TO ${operator}: neither the operator nor the master holds ADMIN OPTION on '${f.owner}'. ` +
213
+ `Run as a role that does: ${stmt};`,
214
+ );
215
+ }
216
+
217
+ // 2. Create: the owner must be able to CREATE in the schema the object lives in.
218
+ if (f.schemaExists && !f.ownerCanCreate) {
219
+ const stmt = `GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}`;
220
+ if (op?.canGrantCreate) plan.asOperator.push(stmt);
221
+ else if (master?.canGrantCreate) plan.asMaster.push(stmt);
222
+ else plan.refusals.push(
223
+ `cannot GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}: neither connection owns the schema or holds CREATE WITH GRANT OPTION. ` +
224
+ `Run as the schema owner: ${stmt};`,
225
+ );
226
+ }
227
+ // An absent schema is not enacted here — it is created by the reconcile builder, whose own
228
+ // preflight then names the owner's missing CREATE. Enactment acts only on what exists.
229
+ }
230
+ return plan;
231
+ }
package/src/plugin.ts CHANGED
@@ -842,6 +842,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
842
842
  if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
843
843
  }
844
844
 
845
+ // Credentials ONLY. Ownership convergence is a SEPARATE action (db:converge-ownership) the CLI
846
+ // runs AFTER it has written the credential to the secret store — so a long/killable ownership
847
+ // step can never sit between the password swap and the secret write (the lockout, see provision.ts).
845
848
  return await runProvision(
846
849
  (payload || {}) as Parameters<typeof runProvision>[0],
847
850
  {
@@ -872,6 +875,49 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
872
875
  );
873
876
  };
874
877
 
878
+ // --- db:converge-ownership — ENACT + ADOPT, AFTER the CLI has written the secret ---
879
+ // The second half of the provisioning flow, split out so it runs strictly after the credential
880
+ // is safely in the secret store: credentials (db:provision) → secret write (CLI) → this. A hard
881
+ // kill here strands nothing — the worst case is an un-converged owner db:reconcile refuses on.
882
+ actions['db:converge-ownership'] = async (payload, _ctx) => {
883
+ const { sql } = await import('drizzle-orm');
884
+ const { convergeOwnership } = await import('./provision');
885
+ const { getAdoptMasterUrl } = await import('./db');
886
+
887
+ const queryRows = async (statement: string): Promise<any[]> => {
888
+ const res: any = await opsDb.execute(sql.raw(statement));
889
+ if (Array.isArray(res)) return res;
890
+ if (Array.isArray(res?.rows)) return res.rows;
891
+ return [];
892
+ };
893
+
894
+ // The MASTER connection ADOPT reassigns through: the linked SST Postgres master, else the
895
+ // ADMIN_DATABASE_URL operator secret (a brownfield/external-RDS app has no component but wires
896
+ // a master-capable admin — see getAdoptMasterUrl). ENACT also routes hard grants here. Null
897
+ // only when NEITHER exists; then ADOPT refuses. Whether the resolved connection can actually
898
+ // reassign is decided by ADOPT's live probe, not by which source won. Closed in the finally.
899
+ const masterUrl = getAdoptMasterUrl();
900
+ let masterClient: any = null;
901
+ let master: { execute: (s: string) => Promise<unknown>; query: (s: string) => Promise<any[]> } | null = null;
902
+ if (masterUrl) {
903
+ const { default: postgres } = await import('postgres');
904
+ masterClient = postgres(masterUrl, { max: 1, connect_timeout: 10, ssl: 'prefer', onnotice: () => {} });
905
+ master = {
906
+ execute: async (s: string) => { await masterClient.unsafe(s); },
907
+ query: async (s: string) => Array.from(await masterClient.unsafe(s)) as any[],
908
+ };
909
+ }
910
+
911
+ try {
912
+ return await convergeOwnership(
913
+ (payload || {}) as Parameters<typeof convergeOwnership>[0],
914
+ { execute: async (statement) => opsDb.execute(sql.raw(statement)), query: queryRows, master },
915
+ );
916
+ } finally {
917
+ if (masterClient) await masterClient.end({ timeout: 5 });
918
+ }
919
+ };
920
+
875
921
 
876
922
  // --- db:rotate — A/B role detection, provisioning, retirement, draining ---
877
923
  // Runs inside the VPC on the operator connection. The CLI orchestrates the
package/src/provision.ts CHANGED
@@ -21,6 +21,25 @@
21
21
  */
22
22
 
23
23
  import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL } from './role-chain';
24
+ import {
25
+ pgVersionRefusal,
26
+ ownerFactsSql,
27
+ granterCapabilitySql,
28
+ planOwnerEnactment,
29
+ type DeclaredOwner,
30
+ type OwnerFactRow,
31
+ type GranterCapabilityRow,
32
+ } from './owner-enactment';
33
+ import {
34
+ adopteeProbeSql,
35
+ planAdoptionReassignment,
36
+ auditAdoption,
37
+ requiresConfirm,
38
+ type Adoptee,
39
+ type AdopteeProbe,
40
+ type RoleAttrs,
41
+ type AdoptionFinding,
42
+ } from './owner-adoption';
24
43
 
25
44
  export interface ProvisionPayload {
26
45
  /** Password for the least-privilege API login. Alphanumeric only. */
@@ -30,6 +49,27 @@ export interface ProvisionPayload {
30
49
  loginRole?: string;
31
50
  adminRole?: string;
32
51
  appRoles?: string[];
52
+ /**
53
+ * Declared owners to ENACT (brownfield ownership convergence, Gap 1). Each (owner, schema) the
54
+ * Models declare for a function/view/matview. Provision grants the operator membership so it can
55
+ * `SET ROLE` the owner, and the owner CREATE on the schema — the two grants db:reconcile needs
56
+ * but (as the least-privilege operator) can only refuse with. Absent/empty = no enactment
57
+ * (byte-identical to pre-feature behavior). Derived from the Models barrel by the CLI.
58
+ */
59
+ declaredOwners?: DeclaredOwner[];
60
+ /**
61
+ * Declared objects to ADOPT (brownfield ownership convergence, Gap 2). Each function/view/matview
62
+ * the Models declare, with its declared owner. Provision reassigns (on the MASTER, in one
63
+ * transaction) any that exist and are mis-owned. Reassigning a SECDEF function/view/matview
64
+ * changes its runs-as identity at CALL time, so those require `confirm`. Absent/empty = no adoption.
65
+ */
66
+ declaredObjects?: Adoptee[];
67
+ /**
68
+ * Explicit confirmation for a runtime-changing ADOPT. Without it, provision REFUSES (and reports
69
+ * the manifest) when any object it would reassign is a SECDEF function, a view, or a matview. The
70
+ * CLI sets this from `--confirm`.
71
+ */
72
+ confirm?: boolean;
33
73
  /**
34
74
  * The declared schema set (ordered, deduped, ending in 'public' — declaredSearchPath). When
35
75
  * present, provision sets it as the ROLE default (ALTER ROLE … SET search_path) on both login
@@ -41,6 +81,15 @@ export interface ProvisionPayload {
41
81
  rotationPairs?: boolean;
42
82
  }
43
83
 
84
+ /**
85
+ * A privileged connection that can both write and READ rows — used for the enactment probes.
86
+ * The operator's own connection has this shape; the master (linked RDS) has it when present.
87
+ */
88
+ export interface ProvisionConnection {
89
+ execute: (sql: string) => Promise<unknown>;
90
+ query: (sql: string) => Promise<any[]>;
91
+ }
92
+
44
93
  export interface ProvisionDeps {
45
94
  /** Run one SQL string on the (privileged) provisioning connection. */
46
95
  execute: (sql: string) => Promise<unknown>;
@@ -48,6 +97,19 @@ export interface ProvisionDeps {
48
97
  connection?: { host: string; port?: number | string; database: string } | null;
49
98
  /** Real login attempt as the admin role — resolves on success, throws on failure. */
50
99
  connectProbe?: (url: string) => Promise<void>;
100
+ /**
101
+ * Read rows on the operator's own (privileged) connection — for the enactment probes and the
102
+ * server-version guard. Absent → enactment is skipped even when declaredOwners is present (an
103
+ * older caller that cannot read; the reconcile preflight still refuses with the fix SQL).
104
+ */
105
+ query?: (sql: string) => Promise<any[]>;
106
+ /**
107
+ * The MASTER connection (the linked RDS master), when one is available. ENACT routes to it the
108
+ * grants the operator cannot make; ADOPT (Gap 2) requires it outright. Absent on a BYO-database
109
+ * app (no linked component) → enactment that needs the master is REFUSED with the fix SQL,
110
+ * never attempted on a connection that does not exist.
111
+ */
112
+ master?: ProvisionConnection | null;
51
113
  /**
52
114
  * Does this role ALREADY exist with LOGIN? When it does, provision is ADDITIVE-ONLY:
53
115
  * it preserves the live password instead of resetting it. A plain `db:provision` used to
@@ -70,6 +132,27 @@ export interface ProvisionResult {
70
132
  /** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
71
133
  adminVerified?: boolean | null;
72
134
  rotationPairsCreated?: boolean;
135
+ /**
136
+ * The result of ENACT (declared-owner convergence, Gap 1). Absent when no owners were declared.
137
+ * `enacted` counts grants actually run; `refusals` are grants neither the operator nor the master
138
+ * could make, each carrying the fix SQL. Refusals do NOT abort credential provisioning (that is
139
+ * the critical, atomic path) — they are reported LOUDLY for the CLI to surface and exit non-zero.
140
+ * The database is not left worse: an un-enacted owner is exactly the state db:reconcile refuses on.
141
+ */
142
+ ownerEnactment?: { enacted: number; refusals: string[] };
143
+ /**
144
+ * The result of ADOPT (declared-object reassignment, Gap 2). Absent when no objects were declared.
145
+ * `reassigned` counts objects moved; `manifest` describes each (identity, kind, secdef, owner
146
+ * change) for the CLI to print; `findings` are the pre-adopt semantic audit; `refusals` block the
147
+ * whole phase (a pre-flight obstacle, a missing master, or a required --confirm that was absent).
148
+ * Like ENACT, refusals never abort credential provisioning and never throw.
149
+ */
150
+ ownerAdoption?: {
151
+ reassigned: number;
152
+ manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>;
153
+ findings: AdoptionFinding[];
154
+ refusals: string[];
155
+ };
73
156
  error?: string;
74
157
  }
75
158
 
@@ -207,5 +290,195 @@ export async function runProvision(
207
290
  result.passwordSet = true;
208
291
  }
209
292
 
293
+ // Ownership convergence (ENACT + ADOPT) is DELIBERATELY NOT here. It is the one part of the
294
+ // provisioning flow that can wait on a lock (ADOPT's transaction) or outlive the Lambda clock,
295
+ // and it sits AFTER the credential swap — so running it inside runProvision would put a
296
+ // long/killable step between the autocommitted `ALTER ROLE … PASSWORD` and the caller's
297
+ // secret-store write. A hard kill there (900s Lambda timeout, SIGKILL) strands the fresh operator
298
+ // password live in the database but never saved: the exact lockout the ordering above exists to
299
+ // prevent, and one no try/catch can catch. So the caller runs `convergeOwnership` as a SEPARATE
300
+ // step, AFTER it has written the secret — credentials → secret → ENACT → ADOPT.
210
301
  return result;
211
302
  }
303
+
304
+ /**
305
+ * Converge declared ownership — ENACT (Gap 1) then ADOPT (Gap 2) — as a step SEPARATE from and
306
+ * AFTER credential provisioning + the secret-store write (see runProvision's closing note). By the
307
+ * time this runs the operator credential is already saved, so a throw or a hard kill here can strand
308
+ * nothing: the worst case is an un-converged owner, exactly the state db:reconcile already refuses on.
309
+ *
310
+ * Both phases are non-fatal: a thrown execute is folded into `refusals`, never propagated. Returns
311
+ * the same `ownerEnactment` / `ownerAdoption` shapes runProvision's result used to carry. Skipped
312
+ * (empty result) when nothing is declared or the caller cannot read (no `query` dep — an older CLI).
313
+ */
314
+ export async function convergeOwnership(
315
+ payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole'>,
316
+ deps: ProvisionDeps,
317
+ ): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'>> {
318
+ const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'> = {};
319
+ if (!deps.query) return out;
320
+ const operator = payload.adminRole ?? 'migrator';
321
+
322
+ if (payload.declaredOwners?.length) {
323
+ try {
324
+ out.ownerEnactment = await enactOwners(payload.declaredOwners, operator, deps);
325
+ } catch (err: any) {
326
+ out.ownerEnactment = { enacted: 0, refusals: [`owner enactment failed (credentials are already saved; nothing is stranded): ${err?.message ?? String(err)}`] };
327
+ }
328
+ }
329
+
330
+ if (payload.declaredObjects?.length) {
331
+ try {
332
+ out.ownerAdoption = await adoptOwners(payload.declaredObjects, deps, !!payload.confirm);
333
+ } catch (err: any) {
334
+ out.ownerAdoption = { reassigned: 0, manifest: [], findings: [], refusals: [`owner adoption failed (the transaction rolled back; credentials are untouched): ${err?.message ?? String(err)}`] };
335
+ }
336
+ }
337
+ return out;
338
+ }
339
+
340
+ const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
341
+
342
+ function toFacts(rows: any[]): OwnerFactRow[] {
343
+ return rows.map((r) => ({
344
+ owner: String(r.owner),
345
+ schema: String(r.schema),
346
+ roleExists: truthy(r.role_exists),
347
+ schemaExists: truthy(r.schema_exists),
348
+ canSetRole: truthy(r.can_set_role),
349
+ ownerCanCreate: truthy(r.owner_can_create),
350
+ }));
351
+ }
352
+
353
+ function toCaps(rows: any[]): GranterCapabilityRow[] {
354
+ return rows.map((r) => ({
355
+ owner: String(r.owner),
356
+ schema: String(r.schema),
357
+ canGrantMembership: truthy(r.can_grant_membership),
358
+ canGrantCreate: truthy(r.can_grant_create),
359
+ }));
360
+ }
361
+
362
+ /**
363
+ * Probe (operator + master), route, and apply the declared-owner grants. The PG16 guard runs
364
+ * first: below 16 the grammar does not exist and provision refuses rather than take a wider action.
365
+ * Master-routed statements need a live master; without one they were already turned into refusals
366
+ * by the router (masterCaps=null), so nothing here attempts a connection that is not there.
367
+ */
368
+ async function enactOwners(
369
+ owners: DeclaredOwner[],
370
+ operator: string,
371
+ deps: ProvisionDeps,
372
+ ): Promise<{ enacted: number; refusals: string[] }> {
373
+ const query = deps.query!;
374
+ const [vRow] = await query(`SELECT current_setting('server_version_num')::int AS v`);
375
+ const vRefusal = pgVersionRefusal(Number(vRow?.v ?? 0));
376
+ if (vRefusal) return { enacted: 0, refusals: [vRefusal] };
377
+
378
+ const facts = toFacts(await query(ownerFactsSql(owners, operator)));
379
+ const operatorCaps = toCaps(await query(granterCapabilitySql(owners)));
380
+ const masterCaps = deps.master ? toCaps(await deps.master.query(granterCapabilitySql(owners))) : null;
381
+
382
+ const plan = planOwnerEnactment(operator, facts, operatorCaps, masterCaps);
383
+ for (const stmt of plan.asOperator) await deps.execute(`${stmt};`);
384
+ for (const stmt of plan.asMaster) await deps.master!.execute(`${stmt};`);
385
+ return { enacted: plan.asOperator.length + plan.asMaster.length, refusals: plan.refusals };
386
+ }
387
+
388
+ function toProbes(rows: any[]): AdopteeProbe[] {
389
+ return rows.map((r) => ({
390
+ identity: String(r.identity),
391
+ exists: truthy(r.exists),
392
+ kindMatches: truthy(r.kind_matches),
393
+ liveSecdef: truthy(r.live_secdef),
394
+ liveOwner: String(r.live_owner ?? ''),
395
+ isExtensionMember: truthy(r.is_extension_member),
396
+ declaredOwnerExists: truthy(r.declared_owner_exists),
397
+ masterPrivsOfCurrent: truthy(r.master_privs_of_current),
398
+ masterCanSetDeclared: truthy(r.master_can_set_declared),
399
+ masterAdminOfDeclared: truthy(r.master_admin_of_declared),
400
+ }));
401
+ }
402
+
403
+ const EMPTY_ADOPTION = { reassigned: 0, manifest: [], findings: [], refusals: [] as string[] };
404
+
405
+ /**
406
+ * Reassign declared objects to their declared owners — the ADOPT phase (Gap 2), master-only and
407
+ * confirm-gated. The reassignment is one transaction with a per-statement lock_timeout, so a stall
408
+ * behind a long reader fails fast and a mid-batch error rolls the whole phase back — never a
409
+ * half-adopted schema (the residue db:reconcile would then choke on).
410
+ */
411
+ async function adoptOwners(
412
+ adoptees: Adoptee[],
413
+ deps: ProvisionDeps,
414
+ confirm: boolean,
415
+ ): Promise<NonNullable<ProvisionResult['ownerAdoption']>> {
416
+ const query = deps.query!;
417
+ const [vRow] = await query(`SELECT current_setting('server_version_num')::int AS v`);
418
+ const vRefusal = pgVersionRefusal(Number(vRow?.v ?? 0));
419
+ if (vRefusal) return { ...EMPTY_ADOPTION, refusals: [vRefusal] };
420
+
421
+ // ADOPT reassigns on a master-capable connection (the linked SST Postgres master, else the
422
+ // ADMIN_DATABASE_URL operator secret — see getAdoptMasterUrl). A connection is present unless the
423
+ // app configures NEITHER. Its actual capability is probed below; this refusal is only the
424
+ // no-connection-at-all case.
425
+ if (!deps.master) {
426
+ return { ...EMPTY_ADOPTION, refusals: [
427
+ 'ADOPT has no master-capable connection to reassign through. Provide an ADMIN_DATABASE_URL ' +
428
+ 'secret for the stage (a privileged operator that owns/administers the objects), or link an ' +
429
+ 'sst.aws.Postgres component. The least-privilege DATABASE_URL cannot reassign a postgres-owned object.',
430
+ ] };
431
+ }
432
+
433
+ const probes = toProbes(await deps.master.query(adopteeProbeSql(adoptees)));
434
+ const plan = planAdoptionReassignment(adoptees, probes);
435
+ if (plan.refusals.length) return { ...EMPTY_ADOPTION, refusals: plan.refusals };
436
+ if (!plan.statements.length) return { ...EMPTY_ADOPTION };
437
+
438
+ // The semantic audit and the confirm gate — computed from what will ACTUALLY reassign, using LIVE
439
+ // facts (the declared owner's live attributes; the object's live SECDEF flag), never the declared
440
+ // guess (Fable BLOCKER C). `probeById` recovers each reassignee's live SECDEF.
441
+ const probeById = new Map(probes.map((p) => [p.identity, p]));
442
+ const owners = new Set<string>();
443
+ for (const a of plan.reassigned) { owners.add(a.currentOwner); owners.add(a.declaredOwner); }
444
+ const attrs = await roleAttrs(deps.master, [...owners]);
445
+ // Audit with the EFFECTIVE runs-as: SECDEF if declared OR live (a live-SECDEF/declared-INVOKER fn
446
+ // still runs as its owner until reconcile replaces the body).
447
+ const findings = auditAdoption(
448
+ plan.reassigned.map((a) => ({ ...a, secdef: a.secdef || !!probeById.get(a.identity)?.liveSecdef })),
449
+ attrs,
450
+ );
451
+ const manifest = plan.reassigned.map((a) => ({
452
+ identity: a.identity, kind: a.kind, secdef: a.secdef || !!probeById.get(a.identity)?.liveSecdef, from: a.currentOwner, to: a.declaredOwner,
453
+ }));
454
+
455
+ const reassignments = plan.reassigned.map((a) => ({
456
+ kind: a.kind, declaredSecdef: a.secdef, liveSecdef: !!probeById.get(a.identity)?.liveSecdef, declaredOwner: a.declaredOwner,
457
+ }));
458
+ if (requiresConfirm(reassignments, attrs) && !confirm) {
459
+ const n = reassignments.filter((r) => requiresConfirm([r], attrs)).length;
460
+ return { reassigned: 0, manifest, findings, refusals: [
461
+ `ADOPT needs --confirm: ${n} object(s) change their runs-as identity at call time or hand ` +
462
+ `ownership to a powerful role (SECDEF functions / views / matviews / superuser-or-BYPASSRLS owners). ` +
463
+ `Review the manifest, then re-run with --confirm.`,
464
+ ] };
465
+ }
466
+
467
+ // One transaction: per-statement lock_timeout, all-or-nothing. `statements` may include SET
468
+ // self-grants ahead of the ALTERs, so the count of reassignments is plan.reassigned, not statements.
469
+ const body = plan.statements.map((s) => `${s};`).join('\n');
470
+ await deps.master.execute(`BEGIN;\nSET LOCAL lock_timeout = '5000ms';\n${body}\nCOMMIT;`);
471
+ return { reassigned: plan.reassigned.length, manifest, findings, refusals: [] };
472
+ }
473
+
474
+ /** Read superuser / bypassrls for a set of roles, on the master. */
475
+ async function roleAttrs(master: ProvisionConnection, roles: string[]): Promise<Record<string, RoleAttrs>> {
476
+ if (!roles.length) return {};
477
+ const list = roles.map((r) => `'${r.replace(/'/g, "''")}'`).join(', ');
478
+ const rows = await master.query(
479
+ `SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN (${list})`,
480
+ );
481
+ const out: Record<string, RoleAttrs> = {};
482
+ for (const r of rows) out[String(r.rolname)] = { superuser: truthy(r.rolsuper), bypassRls: truthy(r.rolbypassrls) };
483
+ return out;
484
+ }