@everystack/cli 0.4.70 → 0.4.71

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,23 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.71] - 2026-09-04
8
+
9
+ ### Added
10
+
11
+ - wire declared ownership into db:provision (B6, CLI half)
12
+ - reconcile refusals point at db:provision, not hand-SQL (B8)
13
+
14
+ ### Fixed
15
+
16
+ - BLOCKER A — reorder ownership convergence AFTER the secret write
17
+ - round-2 BLOCKER-1 — run ownership convergence on the credentials no-op path too
18
+ - align diag header assertion with the current source
19
+
20
+ ### Documentation
21
+
22
+ - brownfield ownership convergence — db:provision ENACT + ADOPT (B10)
23
+
7
24
  ## [0.4.70] - 2026-09-02
8
25
 
9
26
  ### Added
@@ -38,12 +55,33 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
38
55
  - the two-class refusal doctrine for db:apply (p3-doctrine)
39
56
  - the db:apply version-skew refusal and read-disagreement escalation
40
57
 
41
- ## [Unreleased]
58
+ ## [0.4.69] - 2026-08-24
59
+
60
+ ### Fixed
61
+
62
+ - explain paren-only predicate churn instead of leaving it a mystery
63
+ - hedge the paren-only note — it cannot prove "not a change"
64
+
65
+ ## [0.4.68] - 2026-08-24
66
+
67
+ ### Fixed
68
+
69
+ - round-trip a text_col = 'literal' predicate — strip the implicit ::text cast
70
+ - harden the ::text strip — whitespace-tolerant array guard, stacked-cast fixpoint
71
+
72
+ ## [0.4.67] - 2026-08-22
73
+
74
+ ### Added
75
+
76
+ - auto-generated changelogs from conventional commits
42
77
 
43
78
  ### Fixed
44
79
 
45
80
  - secrets test expected .js extension stripped by Metro compat
46
81
  - async RSA key generation — unblocks the event loop in parallel test runs
82
+ - db:provision is additive-only — never reset an existing role's password
83
+ - address closing-gate review — pin ordering, guard half-done recovery
84
+ - address Fable red-team — rotated-stage guard, safe polarity
47
85
 
48
86
  ## [0.4.66] - 2026-08-15
49
87
 
package/README.md CHANGED
@@ -230,7 +230,8 @@ everystack db:fork # fork a deployed stage's DB into a feat
230
230
 
231
231
  # Security
232
232
  everystack db:doctor # is the DB least-privilege + RLS-subject?
233
- everystack db:provision # create the least-privilege role chain (idempotent)
233
+ everystack db:provision # role chain + converge declared ownership (ENACT grants, ADOPT plain objects)
234
+ everystack db:provision --confirm # + ADOPT SECURITY DEFINER functions / views / matviews (runtime change)
234
235
  everystack db:authz:pull|diff|test|owner|report # the authorization contract + red-team
235
236
 
236
237
  # Backups (see docs/backups.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.70",
3
+ "version": "0.4.71",
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>",
@@ -178,7 +178,7 @@
178
178
  "jest": "29.7.0",
179
179
  "react": "19.2.0",
180
180
  "ts-jest": "29.4.9",
181
- "@everystack/server": "0.4.28"
181
+ "@everystack/server": "0.4.29"
182
182
  },
183
183
  "scripts": {
184
184
  "test": "jest",
@@ -647,6 +647,8 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
647
647
  info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply credential-free over the direct lane: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct. (--database-url <url> is the local venue — against a deployed stage it puts a privileged connection string on argv.)');
648
648
  } else if (/timed out|timeout|task timed out/i.test(String(result.error))) {
649
649
  info('A large derived rebuild (dozens of objects) can exceed the ops-Lambda 900-second clock. Re-run credential-free with an unbounded clock: db:reconcile --apply --stage ' + (flags.stage ?? '<stage>') + ' --direct.');
650
+ } else if (/must be owner of/i.test(String(result.error))) {
651
+ info('This is a brownfield object owned by the master (the old tool created it), which the least-privilege operator cannot replace. Run `everystack db:provision --stage ' + (flags.stage ?? '<stage>') + '` first: it ADOPTS pre-existing objects to their declared owners (--confirm for SECURITY DEFINER), so the operator can then manage them. No hand-written ALTER … OWNER.');
650
652
  }
651
653
  process.exit(1);
652
654
  }
@@ -304,14 +304,35 @@ export function provisionPreservedIsRecoverable(args: {
304
304
  * same seam as pipeline-loader). */
305
305
  export interface ServerProvision {
306
306
  runProvision(
307
- payload: { authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean },
307
+ payload: {
308
+ authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean;
309
+ declaredOwners?: Array<{ owner: string; schema: string }>;
310
+ declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
311
+ confirm?: boolean;
312
+ },
308
313
  deps: {
309
314
  execute: (sql: string) => Promise<unknown>;
310
315
  connection?: { host: string; port?: number | string; database: string } | null;
311
316
  connectProbe?: (url: string) => Promise<void>;
312
317
  roleHasLogin?: (role: string) => Promise<boolean>;
318
+ query?: (sql: string) => Promise<any[]>;
319
+ master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
313
320
  },
314
321
  ): Promise<any>;
322
+ /** ENACT + ADOPT, run SEPARATELY and AFTER the secret is written (the lockout-safe ordering). */
323
+ convergeOwnership(
324
+ payload: {
325
+ declaredOwners?: Array<{ owner: string; schema: string }>;
326
+ declaredObjects?: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
327
+ confirm?: boolean;
328
+ adminRole?: string;
329
+ },
330
+ deps: {
331
+ execute: (sql: string) => Promise<unknown>;
332
+ query?: (sql: string) => Promise<any[]>;
333
+ master?: { execute: (sql: string) => Promise<unknown>; query: (sql: string) => Promise<any[]> } | null;
334
+ },
335
+ ): Promise<{ ownerEnactment?: { enacted: number; refusals: string[] }; ownerAdoption?: { reassigned: number; manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>; findings: Array<{ severity: string; identity: string; detail: string }>; refusals: string[] } }>;
315
336
  }
316
337
 
317
338
  /**
@@ -450,6 +471,39 @@ export function declaredSearchPath(schemas: string[]): string[] {
450
471
  return nonPublic.length ? [...nonPublic, 'public'] : [];
451
472
  }
452
473
 
474
+ /** Kinds ADOPT can reassign — triggers have no owner, 'sql' escape-hatch objects are opaque. */
475
+ const ADOPTABLE_KINDS = new Set(['function', 'view', 'materialized view']);
476
+
477
+ /**
478
+ * Read the declared ownership out of the compiled derived source: the unique (owner, schema) pairs
479
+ * to ENACT, and the function/view/matview objects to ADOPT. `secdef` is read from the object's own
480
+ * CREATE (the descriptor compiler emits SECURITY DEFINER into it), so the confirm gate sees which
481
+ * reassignments change runs-as. Objects without a declared owner are left alone (reconcile creates
482
+ * them owned by the operator, no convergence needed).
483
+ */
484
+ export function extractDeclaredOwnership(objects: Array<{ kind: string; schema: string; name: string; identity: string; sql: string; owner?: string }>): {
485
+ declaredOwners: Array<{ owner: string; schema: string }>;
486
+ declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }>;
487
+ } {
488
+ const seen = new Set<string>();
489
+ const declaredOwners: Array<{ owner: string; schema: string }> = [];
490
+ const declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
491
+ for (const o of objects) {
492
+ if (!o.owner) continue;
493
+ const key = `${o.owner}\t${o.schema}`;
494
+ if (!seen.has(key)) { seen.add(key); declaredOwners.push({ owner: o.owner, schema: o.schema }); }
495
+ if (ADOPTABLE_KINDS.has(o.kind)) {
496
+ declaredObjects.push({
497
+ identity: o.identity, schema: o.schema, name: o.name,
498
+ kind: o.kind as 'function' | 'view' | 'materialized view',
499
+ secdef: /SECURITY\s+DEFINER/i.test(o.sql),
500
+ currentOwner: '', declaredOwner: o.owner,
501
+ });
502
+ }
503
+ }
504
+ return { declaredOwners, declaredObjects };
505
+ }
506
+
453
507
  /**
454
508
  * Every schema the API connection must reach — the union of the TABLE schemas (the models
455
509
  * barrel) and the DERIVED-object schemas (views/matviews declared via
@@ -838,6 +892,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
838
892
  // authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
839
893
  // all-public app sets no path (unchanged behavior).
840
894
  let declaredSchemas: string[] = [];
895
+ // Declared ownership (brownfield convergence) — the (owner, schema) pairs to ENACT and the
896
+ // function/view/matview objects to ADOPT, read from the same barrel as the schema set.
897
+ let declaredOwners: Array<{ owner: string; schema: string }> = [];
898
+ let declaredObjects: Array<{ identity: string; schema: string; name: string; kind: 'function' | 'view' | 'materialized view'; secdef: boolean; currentOwner: string; declaredOwner: string }> = [];
841
899
  try {
842
900
  const { resolveModelsPath } = await import('../models-path');
843
901
  const { loadModels } = await import('./db-generate');
@@ -846,7 +904,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
846
904
  try {
847
905
  const { loadDeclaredDerived } = await import('../declared-derived');
848
906
  const declared = await loadDeclaredDerived(flags.models);
849
- if (declared) derivedObjects = declared.objects;
907
+ if (declared) {
908
+ derivedObjects = declared.objects;
909
+ ({ declaredOwners, declaredObjects } = extractDeclaredOwnership(declared.objects));
910
+ }
850
911
  } catch {
851
912
  // No derived layer resolvable — the table schemas still resolve correctly.
852
913
  }
@@ -856,6 +917,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
856
917
  }
857
918
  const searchPath = declaredSearchPath(declaredSchemas);
858
919
  if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
920
+ const confirm = !!flags.confirm;
921
+ const ownershipPayload = { declaredOwners, declaredObjects, confirm };
859
922
 
860
923
  // Generate BOTH login passwords and keep them in memory only — they are written straight
861
924
  // into the secret store and are NEVER printed, logged, or returned to a human.
@@ -870,6 +933,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
870
933
  const { createUrlRunner } = await import('../db-source');
871
934
  const { runner, end } = await createUrlRunner(url);
872
935
  try {
936
+ // Credentials ONLY — ownership convergence runs separately, AFTER the secret is written.
873
937
  return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
874
938
  execute: async (statement) => runner(statement),
875
939
  connection: parseUrlConnection(url),
@@ -950,6 +1014,61 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
950
1014
  success(`Operator login ready: ${result.adminRole} (LOGIN, INHERIT owner) — ${adminMsg}`);
951
1015
  }
952
1016
 
1017
+ // Ownership convergence runs AFTER the secret write (below) — never before. Declared here so the
1018
+ // final exit status can see it.
1019
+ let ownershipIncomplete = false;
1020
+
1021
+ // ENACT + ADOPT, factored out because it must run on BOTH exit paths: the normal one (after the
1022
+ // secret write) AND the additive-only NO-OP path (credentials unchanged, so no secret is written —
1023
+ // but the owners in the model may have changed and still need converging). Running it only on the
1024
+ // normal path made the whole feature a no-op on a re-provision, the common brownfield case: the
1025
+ // stage is already provisioned, the operator adds `owner:` declarations (or re-runs the
1026
+ // review-then-`--confirm` flow), and nothing converged. Credentials are already saved on both
1027
+ // paths by the time this runs, so it can strand nothing.
1028
+ const runConvergence = async (): Promise<void> => {
1029
+ if (!declaredOwners.length && !declaredObjects.length) return;
1030
+ const ownerPayload = { ...ownershipPayload, adminRole: result.adminRole };
1031
+ let owned: any = {};
1032
+ try {
1033
+ if (directUrl) {
1034
+ // The direct venue is ONE privileged connection = operator read AND master; ADOPT's live
1035
+ // master_can_act probe gates it (an under-privileged direct connection refuses precisely).
1036
+ const { convergeOwnership } = await loadServerProvision();
1037
+ const { createUrlRunner } = await import('../db-source');
1038
+ const { runner, end } = await createUrlRunner(directUrl);
1039
+ try {
1040
+ owned = await convergeOwnership(ownerPayload, {
1041
+ execute: async (s) => runner(s),
1042
+ query: async (s) => (await runner(s)) as any[],
1043
+ master: { execute: async (s) => runner(s), query: async (s) => (await runner(s)) as any[] },
1044
+ });
1045
+ } finally {
1046
+ await end?.();
1047
+ }
1048
+ } else {
1049
+ owned = await invokeAction(config.region, opsFunction(config), 'db:converge-ownership', ownerPayload);
1050
+ }
1051
+ } catch (err: any) {
1052
+ warn(`Ownership convergence failed (credentials are already saved — nothing stranded): ${err.message}`);
1053
+ ownershipIncomplete = true;
1054
+ }
1055
+ const enact = owned.ownerEnactment;
1056
+ if (enact) {
1057
+ if (enact.enacted) success(`Owner enactment: ${enact.enacted} grant(s) applied.`);
1058
+ for (const r of enact.refusals) { warn(`Owner enactment: ${r}`); ownershipIncomplete = true; }
1059
+ }
1060
+ const adopt = owned.ownerAdoption;
1061
+ if (adopt) {
1062
+ if (adopt.manifest?.length) {
1063
+ info('Ownership adoption — objects reassigned:');
1064
+ for (const m of adopt.manifest) info(` ${m.identity} [${m.kind}${m.secdef ? ', SECURITY DEFINER' : ''}] ${m.from} → ${m.to}`);
1065
+ }
1066
+ for (const f of adopt.findings ?? []) warn(` audit (${f.severity}): ${f.identity} — ${f.detail}`);
1067
+ if (adopt.reassigned) success(`Ownership adoption: ${adopt.reassigned} object(s) reassigned (one transaction).`);
1068
+ for (const r of adopt.refusals ?? []) { warn(`Ownership adoption: ${r}`); ownershipIncomplete = true; }
1069
+ }
1070
+ };
1071
+
953
1072
  // Additive-only: if the login roles already existed as live logins, provision changed no
954
1073
  // credential — so there is nothing to write and no host to resolve. Never touch the secret
955
1074
  // store on a no-op re-provision (the footgun that reset a live password then locked the stage out).
@@ -966,6 +1085,13 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
966
1085
  });
967
1086
  if (recoverable.ok) {
968
1087
  success(`Roles already provisioned — passwords and secrets left unchanged. Verify the live credential with \`everystack db:doctor --stage ${flags.stage}\`; run \`everystack db:provision --rotate\` to change credentials.`);
1088
+ // Credentials are a no-op, but declared ownership may still need converging — this is the
1089
+ // re-provision path (add owners, or the review-then---confirm retry). Run it, then exit.
1090
+ await runConvergence();
1091
+ if (ownershipIncomplete) {
1092
+ warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
1093
+ process.exit(1);
1094
+ }
969
1095
  return;
970
1096
  }
971
1097
  fail(recoverable.reason);
@@ -1028,6 +1154,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1028
1154
  success(`Secrets written for stage "${flags.stage}". No credential was displayed.`);
1029
1155
  for (const note of plan.notes) info(` ${note}`);
1030
1156
  for (const warning of plan.warnings) info(` ⚠ ${warning}`);
1157
+
1158
+ // OWNERSHIP CONVERGENCE — strictly AFTER the secret write. The credential is now safely in the
1159
+ // store, so a stall or a hard kill in ENACT/ADOPT can strand nothing: the worst case is an
1160
+ // un-converged owner, exactly the state db:reconcile refuses on (and we exit non-zero for it).
1161
+ await runConvergence();
1162
+
1031
1163
  console.log('');
1032
1164
  info('Finish the wiring in sst.config.ts (one-time):');
1033
1165
  info(' API — PRECONDITION: your handler must escalate per request (pgSettings + SET ROLE /');
@@ -1044,6 +1176,14 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
1044
1176
  info(` operator tasks then run as ${result.adminRole}, not the Postgres master.`);
1045
1177
  }
1046
1178
  info(`Verify: everystack db:doctor --stage ${flags.stage}`);
1179
+
1180
+ // Credentials are done; ownership was left un-converged. Exit non-zero so CI and the operator act
1181
+ // on the refusals above (run the named SQL as master, or re-run with --confirm). db:reconcile
1182
+ // would refuse on exactly this state, so this is a real, actionable failure — not a warning to skip.
1183
+ if (ownershipIncomplete) {
1184
+ warn('Ownership was not fully converged — see the refusals above. db:reconcile will refuse until they are resolved.');
1185
+ process.exit(1);
1186
+ }
1047
1187
  }
1048
1188
 
1049
1189
  /** Render a doctor report — identical output whichever venue produced it. */
@@ -408,7 +408,10 @@ export function ownerPreflightRefusal(rows: OwnerPreflightRow[]): string | null
408
408
  if (!missing.size && !unassumable.size && !noCreate.length) return null;
409
409
 
410
410
  const lines: string[] = [
411
- `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied:`,
411
+ `declared function owner(s) cannot be enacted by '${builderName}' — refused before any DDL, so nothing is half-applied.`,
412
+ `The one-command fix is \`everystack db:provision\`: it ENACTS these grants (and ADOPTS any`,
413
+ `pre-existing objects owned by the master) from the declared model, so you never hand-write them.`,
414
+ `The exact statements, if you must run them by hand:`,
412
415
  ];
413
416
  for (const owner of [...missing].sort()) {
414
417
  lines.push(