@everystack/server 0.4.26 → 0.4.28

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 ADDED
@@ -0,0 +1,300 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@everystack/server` are documented here.
4
+
5
+ Format based on [Keep a Changelog](https://keepachangelog.com/).
6
+
7
+ ## [0.4.28] - 2026-09-02
8
+
9
+ ### Added
10
+
11
+ - wire proveDescent into db:apply — ledger read shares the lock session
12
+ - retire --force-descent for the git-native escape + a proven-lineage-lost attestation
13
+ - the stage lane's fast-forward rule is structurally non-droppable (q1-fail-closed)
14
+ - version the db:apply wire contract, both directions (q1-contract-version)
15
+
16
+ ### Fixed
17
+
18
+ - split-brain belt + correct the version-skew remedy (closing gate)
19
+
20
+ ### Changed
21
+
22
+ - delete the orphaned recompile-descent helpers, rewire the illusion of coverage (F1)
23
+
24
+ ## [0.4.26] - 2026-08-15
25
+
26
+ ### Fixed
27
+
28
+ - route rotation DB ops through the ops Lambda (VPC)
29
+
30
+ ## [0.4.25] - 2026-08-15
31
+
32
+ ### Fixed
33
+
34
+ - resolve all closing-gate findings (F1-F8) on Phase B
35
+ - resolve re-review findings N1-N4
36
+
37
+ ## [0.4.24] - 2026-08-15
38
+
39
+ ### Added
40
+
41
+ - auto-call initCredentials on Lambda cold start
42
+
43
+ ### Changed
44
+
45
+ - read credentials from SST secret store, not individual SSM params
46
+
47
+ ## [0.4.23] - 2026-08-15
48
+
49
+ ### Added
50
+
51
+ - runtime SSM credential resolution for live rotation
52
+ - A/B rotation pair provisioning for credential rotation
53
+ - A/B rotation logic — detect, provision, retire, drain
54
+
55
+ ## [0.4.22] - 2026-08-14
56
+
57
+ ### Added
58
+
59
+ - emit x-everystack-release/runtime/created headers from SSR
60
+
61
+ ### Fixed
62
+
63
+ - clear Metro module registry on SSR bundle swap
64
+
65
+ ## [0.4.21] - 2026-08-14
66
+
67
+ ### Fixed
68
+
69
+ - the destructive-apply gate refuses an image-computed fingerprint
70
+ - close the closing-gate findings on the backup fingerprint path
71
+ - bind the fixes to checks that fail, and correct the retraction order
72
+
73
+ ### Documentation
74
+
75
+ - the operator credential is the ops channel only
76
+ - db:provision cannot split a local developer's credentials
77
+
78
+ ## [0.4.20] - 2026-08-12
79
+
80
+ ### Fixed
81
+
82
+ - the example app compiled its tests with type checking off
83
+
84
+ ## [0.4.19] - 2026-08-05
85
+
86
+ ### BREAKING CHANGES
87
+
88
+ - seed and db:reset are declared, not inferred from a stage name
89
+
90
+ ### Added
91
+
92
+ - a cheat gate for stage-name-as-trust-boundary, and the sweep that found two more
93
+
94
+ ## [0.4.18] - 2026-08-05
95
+
96
+ ### Fixed
97
+
98
+ - a 500 returns a request id, never the error text
99
+
100
+ ## [0.4.17] - 2026-08-04
101
+
102
+ ### Fixed
103
+
104
+ - db:doctor printed two remediations that break what they protect
105
+
106
+ ## [0.4.16] - 2026-08-04
107
+
108
+ ### Added
109
+
110
+ - db:session — N statements, one connection, one transaction
111
+
112
+ ### Fixed
113
+
114
+ - route every introspection through one session, and delete the bare SET
115
+
116
+ ## [0.4.15] - 2026-07-28
117
+
118
+ ### Added
119
+
120
+ - expose WAF/caching as an MCP resource — fix the 403-every-PostgREST trap
121
+
122
+ ### Fixed
123
+
124
+ - TCP keepalives on every long-running pg binary
125
+
126
+ ## [0.4.14] - 2026-07-23
127
+
128
+ ### Added
129
+
130
+ - the bring-your-own-database operator path — direct db:provision + optional migrations
131
+ - sstResource() — read any SST link without importing sst
132
+ - credential-free db:reconcile — the derived-edge ops lane — server 0.4.8 + cli 0.4.22
133
+ - credential-free operator lanes — db:refresh --stage + db:doctor direct venue — server 0.4.9 + cli 0.4.23
134
+ - db:refresh --verify-nonempty — the credential-free dark-panel gate — server 0.4.11 + cli 0.4.25
135
+ - db:operator-url ops action — the --direct/db:swap --stage foundation
136
+ - db:apply --direct — the credential-free unbounded-clock write lane
137
+ - db:backfill --stage — the missing stage lane for data jobs
138
+ - search_path lives on the role, not the URL (the real fix)
139
+ - db:exec — DML-only by semantic catalog digest, no role or superuser
140
+ - db data-movement Phase 2 — ephemeral Fargate Task lane for pg-binary work
141
+ - db data-movement Phase 3a — db:backup/db:export run in the ephemeral Task lane
142
+ - db data-movement Phase 3b foundation — snapshot-verify gate + shared live fingerprint
143
+ - db data-movement Phase 3b — destructive apply gated on a verified backup
144
+ - db data-movement — restore→Fargate + delete the pg-tools layer
145
+
146
+ ### Fixed
147
+
148
+ - drop the `sst` import — read SST links from process.env, unbreak every bundle — 0.4.5
149
+ - resolve SST links delivered via the encrypted key file — 0.4.7
150
+ - db:reconcile --only <ids> — the recovery exit from a mistaken --rebaseline — server 0.4.10 + cli 0.4.24
151
+ - opaque the expo-router loader-data blob so crawlers can't harvest /index URLs
152
+ - db data-movement — Fable build-gate fixes (Task S3 grant + provable fp)
153
+ - full backup excludes the everystack ops schema (restore --clean choked on it)
154
+
155
+ ## [0.4.4] - 2026-07-17
156
+
157
+ ### Fixed
158
+
159
+ - createDb honors an explicit sslmode in the connection URL
160
+
161
+ ## [0.4.3] - 2026-07-16
162
+
163
+ ### Added
164
+
165
+ - canonical-sync artifact verbs (gap B)
166
+
167
+ ## [0.4.2] - 2026-07-16
168
+
169
+ ### Added
170
+
171
+ - pgDumpLayer() declarative layer resource; deprecate PGDUMP_LAYER_ARN
172
+ - zero-config backup-layer guidance — probe/doctor/apply preflight
173
+
174
+ ### Fixed
175
+
176
+ - the flip guidance can no longer dark-site an API — rides server 0.4.0
177
+ - admin role grants are cycle-safe and adoptive — 0.4.1
178
+
179
+ ## [0.4.0] - 2026-07-15
180
+
181
+ ### Added
182
+
183
+ - credential-free staged db:apply
184
+ - ingest pipeline framework (Phase 3, Bricks 1-3)
185
+ - surface pipeline last-run in db:sync/db:plan (Phase 3, Brick 4)
186
+ - credential-free pipeline:run --stage (Phase 3, Brick 5)
187
+ - WAF rate limiting answers 429 + Retry-After, not the default 403
188
+ - live drift + doctor — what the content hashes can't see (Brick B4, read-model-everywhere)
189
+ - built-in _health action; the seed module loads lazily — 0.3.6
190
+ - db:provision provisions BOTH sides of the credential split — 0.3.7
191
+ - forward-port afterRpc + read-replica routing from the 0.2.x line
192
+
193
+ ### Fixed
194
+
195
+ - optional-peer AWS clients fail clean
196
+
197
+ ## [0.3.3] - 2026-07-04
198
+
199
+ ### Added
200
+
201
+ - IAM auth for web deploys, replace Sharp layer with SST bundling
202
+ - rename @everystack/updates → @everystack/cli, add db:migrate/db:seed, remove AdminSecret
203
+ - add StorageAdapter head(), ETag-based SSR reload, SigV4a for KVS
204
+ - add mode: postgres — pgcrypto auth via postgres.js
205
+ - configurable per-origin cache headers + image EXIF fix
206
+ - consolidate drizzle migrations + logging schema updates
207
+ - add afterExtract hook to WebHandlerOptions
208
+ - EAS-compatible OTA updates — branches, groups, channels, SSR
209
+ - add web publish versioning matching mobile pattern
210
+ - add X-Bundle-Key response header to SSR for debugging
211
+ - add optional logSink for structured request logging
212
+ - cdn auth helper — verify JWTs at the edge (no Lambda hop)
213
+ - composeViewerRequest + Feature interface + minifier
214
+ - cookie-based SSR authentication
215
+ - compact CFF verifier + dead code stripping for 10KB budget
216
+ - move hydration fixes into framework, remove auth cache splitting
217
+ - add `everystack diag` command for deployed page freshness diagnostics
218
+ - add plugin architecture for composable Lambda handlers
219
+ - extract ssrPlugin into @everystack/server/plugin
220
+ - extract generic actions into package plugins
221
+ - add auth plugin with layered security and client credentials
222
+ - replace publicPaths with requireAuth pattern
223
+ - add publicMethods to authFeature for anonymous reads at edge
224
+ - add SQS adapter for progressive job dispatch
225
+ - S3 origin cache for processed images
226
+ - HEIC decode fallback via heic-decode, passthrough for unsupported formats
227
+ - content-addressed asset deduplication, fix Metro resolution
228
+ - add EventBridge Scheduler support for cancel/reschedule
229
+ - add createSecrets helper and transparent naming support
230
+ - expose ctx, notifications, and rpc in console eval scope
231
+ - add ActiveRecord-style model layer for console
232
+ - add dot commands and schema introspection
233
+ - add auth context with login, asUser, and pgSettings
234
+ - add sandbox mode for safe exploration
235
+ - add .login with hidden password, current_user in scope, history redaction
236
+ - add content-hash fingerprinting for media assets
237
+ - split operator credential from the API connection
238
+ - add cache-state + timing observability headers to image handler
239
+ - edge request id as traceId + shared timing headers
240
+ - migration runner that cannot lie
241
+ - fail-closed bundle-boundary guard (@everystack/server/testing)
242
+ - web bundle guard (assertWebBundleClean)
243
+ - withRole/roleFor + explicit DATABASE_URL precedence
244
+ - db:doctor — verify the DB is least-privilege + RLS-subject
245
+ - db:provision — the role provisioner + db:doctor remediation
246
+ - least-priv client auth (invoker+RLS) + db:doctor auth.users carve-out
247
+ - make db:doctor FORCE-RLS carve-outs app-configurable
248
+ - ship jest-preset so consumers can import @everystack/server/testing
249
+ - db:authz:test + the db:authz:probe ops action — red-team a deployed stage
250
+ - db:doctor enforces "app-reachable tables require RLS"
251
+ - db:backup B1 — pg_dump layer + version-gate probe
252
+ - db:backup B2 — pg_dump/pg_restore ops actions, streamed to S3
253
+ - db:doctor functions teeth + derive every FORCE carveout
254
+ - db:fork — branch stages fork the database, not just the code (branch-stages brick 1+2)
255
+
256
+ ### Fixed
257
+
258
+ - replace crossfilter2 with plain JS, fix ESM test failures
259
+ - drop immutable from image default — use consistent 60s browser TTL
260
+ - require crypto module in CFF verifiers, strip routeSite
261
+ - skip edge auth for non-API paths when tableFromPath is enabled
262
+ - psql Lambda-only queries, composite key notify, logging defaults
263
+ - include stubs directory in published package
264
+ - resolve 5 deployment issues from first external testing
265
+ - resolve 7 high-priority security findings from red team audit
266
+ - fix sharp mock and S3Client mock for ESM test compatibility
267
+ - resolve auth test failures and logging build error
268
+ - pin all dependency versions to lockfile-resolved values
269
+ - strip _a auth flag from CFF query params
270
+ - upscale source to fill box when fit=cover, passthrough non-image files
271
+ - handle HEIC decode failures, oversized passthrough, uncached errors
272
+ - replace infrastructure headers with single X-Server-Debug timestamp
273
+ - version bumps and publish fixes for cli, server, jobs
274
+ - enforce RLS by default and handle unstringifiable results
275
+ - ops Lambda boots the full app so the console keeps rpc/jobs
276
+ - short edge TTL for SSR HTML so it refreshes within 60s
277
+ - never public-cache cookie-authenticated SSR responses
278
+ - release-unique SSR build dir so warm Lambdas pick up new releases
279
+ - operator connection falls back to master, never the authenticator
280
+ - correct jest-preset usage example in doc comment
281
+ - jest-preset self-pins @everystack/server/testing (cold-cache TS2307)
282
+ - detect HEIC by magic bytes, not Sharp
283
+
284
+ ### Changed
285
+
286
+ - extract server infra into @expo-stack/server + @expo-stack/auth packages
287
+ - migrate all package schemas to everyschema canonical tables
288
+ - consolidate authorization to PostgreSQL as single source of truth
289
+ - extract generic code to packages, remove dead code
290
+ - drop type module from all packages, switch Jest to CJS mode
291
+ - consolidate Jest configs into shared base
292
+ - fold ops handler into createPluginLambdaHandler({ http: false })
293
+
294
+ ### Documentation
295
+
296
+ - rewrite documentation around V1→V2→V3 progressive complexity model
297
+ - prepare documentation for open-source release
298
+ - close remaining security punchlist items (L6, M3)
299
+ - warn that jest-preset owns `transform` (the TS2307 footgun)
300
+ - v3 (0.3.0) documentation review — accurate READMEs + guides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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>",
@@ -20,7 +20,8 @@
20
20
  "src",
21
21
  "stubs",
22
22
  "jest-preset.js",
23
- "README.md"
23
+ "README.md",
24
+ "CHANGELOG.md"
24
25
  ],
25
26
  "exports": {
26
27
  ".": {
@@ -186,8 +187,8 @@
186
187
  "sst": "4.13.1",
187
188
  "ts-jest": "29.4.9",
188
189
  "typescript": "5.9.3",
189
- "@everystack/cli": "0.4.65",
190
- "@everystack/auth": "0.4.3"
190
+ "@everystack/cli": "0.4.70",
191
+ "@everystack/auth": "0.4.4"
191
192
  },
192
193
  "scripts": {
193
194
  "test": "jest",
package/src/plugin.ts CHANGED
@@ -12,6 +12,18 @@ import type {
12
12
  APIGatewayProxyEventV2,
13
13
  APIGatewayProxyStructuredResultV2,
14
14
  } from 'aws-lambda';
15
+ import type { ApplyPlanOptions } from '@everystack/cli/apply';
16
+
17
+ /**
18
+ * The stage lane's apply options, with `checkDescent` made REQUIRED. The fast-forward rule is the
19
+ * one gate that crosses the wire on every stage apply (additive included); typing it non-optional
20
+ * here forbids the conditional spread that once let a missing descent verdict silently skip it.
21
+ * verifyAuthority/verifySnapshot stay optional — they are destructive-only, and the stage lane
22
+ * refuses destructive plans outright, so they never arise on this lane.
23
+ */
24
+ type StageApplyOptions = ApplyPlanOptions & {
25
+ checkDescent: NonNullable<ApplyPlanOptions['checkDescent']>;
26
+ };
15
27
  import {
16
28
  createRouter,
17
29
  eventToRequest,
@@ -191,11 +203,17 @@ export function createPluginLambdaHandler(
191
203
  // the action lookup (it must answer even when initialization itself is the failure) and
192
204
  // reserved: an app action named `_health` is never reached.
193
205
  if (event._action === '_health') {
206
+ // q1-contract-version direction B: advertise the db:apply wire-contract version so a NEWER
207
+ // CLI detects an OLDER deployed stage BEFORE it dispatches a write (prevention, not a
208
+ // post-write alarm). Stamped on BOTH branches, so a stage whose DB init is failing still
209
+ // tells the CLI its contract — an absent field means the deployed server predates the
210
+ // handshake. Old servers simply omit it; the CLI reads absent as "redeploy the stage".
211
+ const { APPLY_CONTRACT_VERSION } = await import('@everystack/cli/apply');
194
212
  try {
195
213
  const { actions } = await ensureInitialized();
196
- return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort() };
214
+ return { ok: true, mode: httpEnabled ? 'http' : 'ops', actions: Object.keys(actions).sort(), applyContract: APPLY_CONTRACT_VERSION };
197
215
  } catch (error) {
198
- return { ok: false, error: String(error) };
216
+ return { ok: false, error: String(error), applyContract: APPLY_CONTRACT_VERSION };
199
217
  }
200
218
  }
201
219
  const { ctx, actions } = await ensureInitialized();
@@ -800,9 +818,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
800
818
  // --- db:provision — BOTH sides of the credential split, in the safe order ---
801
819
  // Runs as the operator and creates ONLY roles (no database, no tables, no schema), so
802
820
  // it is safe on an existing database: an app that brings its own DATABASE_URL can run
803
- // this to adopt the least-privilege split without re-provisioning anything. The
804
- // auto-provisioning construct invokes it at deploy time with a generated password.
805
- // With adminPassword, also creates the operator login (migrator) and VERIFIES its
821
+ // this to adopt the least-privilege split without re-provisioning anything. It is
822
+ // ADDITIVE-ONLY: an already-live login role keeps its password (only db:provision --rotate
823
+ // changes an existing one). With adminPassword, also creates the operator login (migrator) and VERIFIES its
806
824
  // real connectivity BEFORE the authenticator password is set — the ordering that
807
825
  // keeps a BYO-database app from sawing off its own ops plane (see provision.ts).
808
826
  actions['db:provision'] = async (payload, _ctx) => {
@@ -829,6 +847,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
829
847
  {
830
848
  execute: async (statement) => opsDb.execute(sql.raw(statement)),
831
849
  connection,
850
+ // Additive-only: an already-live login role keeps its password (never reset on re-provision).
851
+ roleHasLogin: async (role) => {
852
+ const res: any = await opsDb.execute(sql`SELECT 1 FROM pg_roles WHERE rolname = ${role} AND rolcanlogin`);
853
+ if (Array.isArray(res)) return res.length > 0;
854
+ if (Array.isArray(res?.rows)) return res.rows.length > 0;
855
+ // Unrecognized driver shape — PRESERVE the credential. Skipping a password is recoverable
856
+ // (the CLI half-done guard catches it); resetting a live one on a false-negative is the lockout.
857
+ return true;
858
+ },
832
859
  connectProbe: async (url) => {
833
860
  const { default: postgres } = await import('postgres');
834
861
  // 'prefer' handles both TLS-required RDS and plain local Postgres.
@@ -949,12 +976,21 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
949
976
  // direct --database-url path runs, only here the connection never leaves the
950
977
  // Lambda. A destructive plan snapshots first (db:backup), in order.
951
978
  actions['db:apply'] = async (payload, ctx) => {
952
- const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage, snapshotRef } =
979
+ const { plan, contractVersion, actor, gitRef, dirty, descentVerdict, descentAttested, authorityVerdict, stage, snapshotRef } =
953
980
  (payload ?? {}) as {
954
981
  plan?: any;
982
+ /** The CLI's db:apply wire-contract version. Checked for EXACT equality against this
983
+ * server's APPLY_CONTRACT_VERSION — a skew refuses before any write (direction A). */
984
+ contractVersion?: number;
955
985
  actor?: string | null;
956
986
  gitRef?: string | null;
987
+ /** The CLI applied from a dirty worktree — recorded on the schema_log witness so a
988
+ * later descent ignores this row's git_ref. */
989
+ dirty?: boolean;
957
990
  descentVerdict?: { ok: boolean; reason?: string };
991
+ /** When the CLI honoured a --attest-lineage-lost waiver, the note to record in the
992
+ * schema_log outcome — the durable audit of the deliberate descent waiver. */
993
+ descentAttested?: string;
958
994
  authorityVerdict?: { ok: boolean; reason?: string };
959
995
  stage?: string;
960
996
  /** An explicit backup id to verify; absent → the gate auto-resolves the stage's newest. */
@@ -963,10 +999,29 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
963
999
  if (!plan || typeof plan !== 'object' || !Array.isArray(plan.statements)) {
964
1000
  return { error: 'db:apply requires { plan } — a reviewed edge minted by db:plan' };
965
1001
  }
966
- const { executeApplyPlan, PLAN_VERSION } = await import('@everystack/cli/apply');
1002
+ const { executeApplyPlan, PLAN_VERSION, APPLY_CONTRACT_VERSION } = await import('@everystack/cli/apply');
967
1003
  if (plan.v !== PLAN_VERSION) {
968
1004
  return { error: `plan version ${plan.v} is not this server's ${PLAN_VERSION} — re-mint with a matching db:plan` };
969
1005
  }
1006
+ // q1-contract-version direction A: the db:apply WIRE CONTRACT (payload keys + the gates this
1007
+ // server runs from them) is versioned apart from the plan. An exact-equality mismatch — an
1008
+ // older CLI that declares none, or a bumped one — refuses before any write, so a renamed or
1009
+ // dropped payload key can never ride through as a silently skipped gate. Redeploying the
1010
+ // stage's ops Lambda ships the matching cli it bundles, so the versions realign together.
1011
+ if (contractVersion !== APPLY_CONTRACT_VERSION) {
1012
+ // This fires only for an OLDER CLI: a current CLI catches the skew at the _health handshake and
1013
+ // never dispatches. So the remedy leads with upgrading the CLI, not redeploying the (current) stage.
1014
+ return { error: `this db:apply payload declares apply-contract v${contractVersion ?? '(none)'} but this stage's ops Lambda enforces v${APPLY_CONTRACT_VERSION} — your everystack CLI is older than the deployed stage. Upgrade the CLI to match (or, if the stage was rolled back, redeploy it); nothing was applied.` };
1015
+ }
1016
+ // q1-fail-closed: descent (the fast-forward rule) is the one gate that crosses the wire on
1017
+ // EVERY stage apply, additive included. It is NOT waivable by omission. A payload with no
1018
+ // well-formed descent verdict is an older CLI or a malformed client — refuse before any write,
1019
+ // never silently skip the rule. (The conditional-spread that let a missing verdict skip
1020
+ // checkDescent was the INFO-1 bug; StageApplyOptions now forbids dropping the hook, and this
1021
+ // guards the evidence that feeds it.)
1022
+ if (!descentVerdict || typeof descentVerdict.ok !== 'boolean') {
1023
+ return { error: 'this db:apply payload carries no descent verdict — the fast-forward rule cannot be waived by omission. Upgrade the everystack CLI (an older CLI omits it), or this is a client bug.' };
1024
+ }
970
1025
 
971
1026
  const { sql } = await import('drizzle-orm');
972
1027
  const runner = async (query: string): Promise<any[]> => {
@@ -986,14 +1041,17 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
986
1041
  const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
987
1042
  let snapshotId: string | undefined;
988
1043
  try {
989
- const result = await executeApplyPlan(runner, session, plan, {
1044
+ // checkDescent is REQUIRED by StageApplyOptions — no conditional spread can drop it. The
1045
+ // verdict is guaranteed well-formed by the guard above; the hook enforces + records it here,
1046
+ // in order, atomic with the lock. Authority/snapshot stay conditional (destructive-only, and
1047
+ // the stage lane refuses destructive outright, so they never light up on this lane).
1048
+ const applyOptions: StageApplyOptions = {
990
1049
  actor: actor ?? null,
991
1050
  gitRef: gitRef ?? null,
992
- // Descent/authority are decided by the CLI (checkout + caller identity)
993
- // and enforced + recorded here, in order, atomic with the lock.
994
- ...(descentVerdict
995
- ? { verifyDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }) }
996
- : {}),
1051
+ dirty: dirty === true,
1052
+ checkDescent: async () => (descentVerdict.ok ? { ok: true as const } : { ok: false as const, reason: descentVerdict.reason ?? 'descent refused' }),
1053
+ // A honoured --attest-lineage-lost waiver is recorded in the schema_log outcome.
1054
+ ...(descentAttested ? { descentNote: () => descentAttested } : {}),
997
1055
  ...(destructive && authorityVerdict
998
1056
  ? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
999
1057
  : {}),
@@ -1021,8 +1079,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1021
1079
  },
1022
1080
  }
1023
1081
  : {}),
1024
- });
1025
- return snapshotId ? { ...result, snapshotId } : result;
1082
+ };
1083
+ const result = await executeApplyPlan(runner, session, plan, applyOptions);
1084
+ // Echo the contract version the write ran under (SHOULD, legibility — the safety mechanism is
1085
+ // the pre-transaction refusal above, not this field).
1086
+ return { ...result, contractVersion: APPLY_CONTRACT_VERSION, ...(snapshotId ? { snapshotId } : {}) };
1026
1087
  } catch (err: any) {
1027
1088
  return { error: err?.message || String(err) };
1028
1089
  }
package/src/provision.ts CHANGED
@@ -48,6 +48,16 @@ export interface ProvisionDeps {
48
48
  connection?: { host: string; port?: number | string; database: string } | null;
49
49
  /** Real login attempt as the admin role — resolves on success, throws on failure. */
50
50
  connectProbe?: (url: string) => Promise<void>;
51
+ /**
52
+ * Does this role ALREADY exist with LOGIN? When it does, provision is ADDITIVE-ONLY:
53
+ * it preserves the live password instead of resetting it. A plain `db:provision` used to
54
+ * `ALTER ROLE … WITH LOGIN PASSWORD` unconditionally, so re-running it on a stage whose
55
+ * secrets already matched rewrote the live password to a fresh value — and if the CLI
56
+ * then failed to write the secret, the stage was locked out (needed the RDS master to
57
+ * recover). Only `--rotate` changes an existing password. Absent → treated as
58
+ * not-existing (the role is being created now, so its password IS set).
59
+ */
60
+ roleHasLogin?: (role: string) => Promise<boolean>;
51
61
  }
52
62
 
53
63
  export interface ProvisionResult {
@@ -89,9 +99,16 @@ export async function runProvision(
89
99
  return { error: err?.message || String(err) };
90
100
  }
91
101
 
102
+ // Existence check BEFORE the role chain runs — the chain CREATEs the roles, so after it
103
+ // every role "exists". A role that already had LOGIN *before* this run is a live credential
104
+ // we must not reset (additive-only); one created now gets its password set.
105
+ const login = loginRole ?? 'authenticator';
106
+ const admin = adminRole ?? 'migrator';
107
+ const authExisted = !!authPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(login));
108
+ const adminExisted = !!adminPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(admin));
109
+
92
110
  await deps.execute(roleSql);
93
111
 
94
- const login = loginRole ?? 'authenticator';
95
112
  const result: ProvisionResult = {
96
113
  provisioned: true,
97
114
  loginRole: login,
@@ -107,35 +124,43 @@ export async function runProvision(
107
124
 
108
125
  // Admin side FIRST — created and verified before the authenticator swap can happen.
109
126
  if (adminPassword && adminSql) {
110
- const admin = adminRole ?? 'migrator';
111
127
  await deps.execute(adminSql);
112
- await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
128
+ result.adminRole = admin;
113
129
  // The operator role gets the same role-default search_path — this is the role db:swap/apply/
114
130
  // reconcile --direct connect as, so a clean URL (no ?search_path=) still resolves bare refs.
131
+ // Safe + idempotent even when adopting an existing role.
115
132
  const adminSearchPathSql = searchPath ? generateSearchPathSQL(admin, searchPath) : null;
116
133
  if (adminSearchPathSql) await deps.execute(adminSearchPathSql);
117
- result.adminRole = admin;
118
- result.adminPasswordSet = true;
119
-
120
- if (deps.connection && deps.connectProbe) {
121
- const { host, port, database } = deps.connection;
122
- const url = `postgresql://${admin}:${adminPassword}@${host}:${port ?? 5432}/${database}`;
123
- try {
124
- await deps.connectProbe(url);
125
- result.adminVerified = true;
126
- } catch (err: any) {
127
- // The whole point of the ordering: a broken admin login must abort the swap,
128
- // or a BYO-database app loses its only privileged path.
129
- return {
130
- error:
131
- `admin login verification failed for role '${admin}': ${err?.message || String(err)} — ` +
132
- 'the authenticator password was NOT set and DATABASE_URL must not be rewritten.',
133
- adminRole: admin,
134
- adminVerified: false,
135
- };
136
- }
134
+
135
+ if (adminExisted) {
136
+ // Additive-only: the operator login already exists with a password the app is using.
137
+ // Do NOT reset it, and do NOT probe with a password we never set (--rotate changes it).
138
+ result.adminPasswordSet = false;
139
+ result.adminVerified = null; // unchanged — verify via db:doctor
137
140
  } else {
138
- result.adminVerified = null; // no way to probe — caller should verify via db:doctor
141
+ await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
142
+ result.adminPasswordSet = true;
143
+
144
+ if (deps.connection && deps.connectProbe) {
145
+ const { host, port, database } = deps.connection;
146
+ const url = `postgresql://${admin}:${adminPassword}@${host}:${port ?? 5432}/${database}`;
147
+ try {
148
+ await deps.connectProbe(url);
149
+ result.adminVerified = true;
150
+ } catch (err: any) {
151
+ // The whole point of the ordering: a broken admin login must abort the swap,
152
+ // or a BYO-database app loses its only privileged path.
153
+ return {
154
+ error:
155
+ `admin login verification failed for role '${admin}': ${err?.message || String(err)} — ` +
156
+ 'the authenticator password was NOT set and DATABASE_URL must not be rewritten.',
157
+ adminRole: admin,
158
+ adminVerified: false,
159
+ };
160
+ }
161
+ } else {
162
+ result.adminVerified = null; // no way to probe — caller should verify via db:doctor
163
+ }
139
164
  }
140
165
  }
141
166
 
@@ -154,7 +179,6 @@ export async function runProvision(
154
179
  }
155
180
 
156
181
  if (adminSql) {
157
- const admin = adminRole ?? 'migrator';
158
182
  // Admin pairs need owner membership to run DDL (F4 fix)
159
183
  const adminPairSql = generateRotationPairSQL(admin, 'INHERIT NOBYPASSRLS');
160
184
  await deps.execute(adminPairSql);
@@ -176,7 +200,9 @@ export async function runProvision(
176
200
  }
177
201
 
178
202
  // Authenticator LAST — only reached when the admin side (if requested) held up.
179
- if (authPassword) {
203
+ // Additive-only: skip the password when the authenticator already exists as a live login,
204
+ // so a re-provision never rewrites the credential the app is currently using.
205
+ if (authPassword && !authExisted) {
180
206
  await deps.execute(`ALTER ROLE ${login} WITH LOGIN PASSWORD '${authPassword}';`);
181
207
  result.passwordSet = true;
182
208
  }