@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 +300 -0
- package/package.json +5 -4
- package/src/plugin.ts +76 -15
- package/src/provision.ts +52 -26
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.
|
|
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.
|
|
190
|
-
"@everystack/auth": "0.4.
|
|
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.
|
|
804
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|