@everystack/server 0.4.13 → 0.4.14

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/README.md CHANGED
@@ -149,8 +149,7 @@ Actions contributed (each is an `everystack db:*` command):
149
149
  | `db:psql` / `db:query` | connection info / read-only SQL via Lambda |
150
150
  | `db:doctor` | audit: is the API connection least-privilege + RLS-subject? Flags BYPASSRLS, un-FORCEd RLS, and **app-reachable tables without RLS** |
151
151
  | `db:provision` | create the least-privilege role chain on an existing DB (idempotent; roles only) |
152
- | `db:backup:probe` | verify the `pg_dump` layer is attached and `pg_dump` major ≥ the server major |
153
- | `db:backup` | `pg_dump -Fc` → gzip → S3 (streamed); awaits the child exit code, deletes the partial on failure |
152
+ | `db:backup` | dispatched to the Fargate Task lane: `pg_dump -Fc` → gzip → S3 (streamed), fingerprint-stamped; awaits the child exit code, deletes the partial on failure |
154
153
  | `db:backups` | list a stage's backups |
155
154
  | `db:restore` | S3 → gunzip → `pg_restore --clean --if-exists` (requires `confirm:true`) |
156
155
  | `db:authz:probe` | run the CLI-built authorization red-team SQL in a rolled-back transaction |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
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>",
@@ -85,9 +85,13 @@
85
85
  "types": "./src/pipeline.ts",
86
86
  "default": "./src/pipeline.ts"
87
87
  },
88
- "./infra": {
89
- "types": "./src/infra.ts",
90
- "default": "./src/infra.ts"
88
+ "./backup": {
89
+ "types": "./src/backup.ts",
90
+ "default": "./src/backup.ts"
91
+ },
92
+ "./backup-run": {
93
+ "types": "./src/backup-run.ts",
94
+ "default": "./src/backup-run.ts"
91
95
  }
92
96
  },
93
97
  "peerDependencies": {
@@ -99,7 +103,6 @@
99
103
  "@aws-sdk/signature-v4a": ">=3.1048.0",
100
104
  "@everystack/auth": ">=0.1.0",
101
105
  "@everystack/cli": ">=0.1.0",
102
- "@everystack/pg-tools": ">=0.4.0",
103
106
  "drizzle-orm": "0.41.0",
104
107
  "heic-decode": "2.1.0",
105
108
  "postgres": "3.4.9",
@@ -119,9 +122,6 @@
119
122
  "sst": {
120
123
  "optional": true
121
124
  },
122
- "@everystack/pg-tools": {
123
- "optional": true
124
- },
125
125
  "@everystack/auth": {
126
126
  "optional": true
127
127
  },
@@ -160,8 +160,7 @@
160
160
  "ts-jest": "29.4.9",
161
161
  "typescript": "5.9.3",
162
162
  "@everystack/auth": "0.4.0",
163
- "@everystack/pg-tools": "0.4.1",
164
- "@everystack/cli": "0.4.29"
163
+ "@everystack/cli": "0.4.31"
165
164
  },
166
165
  "scripts": {
167
166
  "test": "jest",
package/src/backup-run.ts CHANGED
@@ -15,12 +15,12 @@
15
15
  import { spawn } from 'node:child_process';
16
16
  import { createGzip, createGunzip } from 'node:zlib';
17
17
  import { pipeline } from 'node:stream/promises';
18
- import { randomBytes } from 'node:crypto';
19
18
  import type { Readable } from 'node:stream';
20
19
  import {
21
- backupPrefix, backupKey, metaKey, backupId, idForKey, keyForId, utcStamp,
22
- artifactKey, artifactId, artifactPrefix, artifactIdForKey,
20
+ backupPrefix, metaKey, idForKey, keyForId,
23
21
  pgEnvFromUrl, dbNameFromUrl, pgDumpArgs, pgRestoreArgs,
22
+ restoreIntentPrefix,
23
+ type BackupManifest, type RestoreIntent,
24
24
  } from './backup';
25
25
 
26
26
  /** Where backups live + the operator connection pg_dump/pg_restore use. */
@@ -36,6 +36,8 @@ export interface BackupSummary {
36
36
  key: string;
37
37
  bytes?: number;
38
38
  lastModified?: string;
39
+ /** The live base-schema fingerprint stamped at dump time (present on B3+ backups). */
40
+ fingerprint?: string;
39
41
  }
40
42
 
41
43
  /**
@@ -77,19 +79,31 @@ async function getS3(region?: string): Promise<any> {
77
79
  return new S3Client(region ? { region } : {});
78
80
  }
79
81
 
82
+ /** A backup run's target — the id+key the DISPATCHER minted (mintBackupTarget) + its stage. */
83
+ export interface BackupTarget {
84
+ stage: string;
85
+ id: string;
86
+ key: string;
87
+ /**
88
+ * The live base-schema fingerprint at dump time (liveBaseFingerprint), stamped into the manifest so
89
+ * a destructive db:apply can prove `backup.fp == plan.from`. The Task computes it over the same
90
+ * connection it dumps. Absent → the manifest carries no fp and the safety gate refuses it (a
91
+ * pre-B3 or fp-less backup can't be a verified net).
92
+ */
93
+ fingerprint?: string;
94
+ }
95
+
80
96
  /**
81
97
  * pg_dump -Fc → gzip → S3 (multipart, streamed). Writes a `.meta.json` sidecar on success.
82
98
  * Throws (after deleting the partial object) if pg_dump exits non-zero — the dump is never trusted
83
- * on a successful upload alone.
99
+ * on a successful upload alone. The id+key are minted by the caller (the dispatcher) so the CLI
100
+ * knows the artifact at dispatch time; this IO never stamps its own clock into the id.
84
101
  */
85
- export async function runBackup(deps: BackupRunDeps, stage: string): Promise<BackupSummary> {
102
+ export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Promise<BackupSummary> {
86
103
  const { bucket, region, adminUrl } = deps;
104
+ const { stage, id, key, fingerprint } = target;
87
105
  const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
88
- const stamp = utcStamp(new Date());
89
- const shortId = randomBytes(3).toString('hex');
90
- const key = backupKey(stage, stamp, shortId);
91
106
  const mkey = metaKey(key);
92
- const id = backupId(stage, stamp, shortId);
93
107
 
94
108
  const s3 = await getS3(region);
95
109
  const { Upload } = await importOptionalAws(
@@ -139,13 +153,13 @@ export async function runBackup(deps: BackupRunDeps, stage: string): Promise<Bac
139
153
  bytes = head.ContentLength;
140
154
  } catch { /* size is best-effort */ }
141
155
 
142
- const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key };
156
+ const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key, ...(fingerprint ? { fingerprint } : {}) };
143
157
  await s3.send(new PutObjectCommand({
144
158
  Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
145
159
  ContentType: 'application/json', ServerSideEncryption: 'AES256',
146
160
  }));
147
161
 
148
- return { id, key, bytes };
162
+ return { id, key, bytes, ...(fingerprint ? { fingerprint } : {}) };
149
163
  }
150
164
 
151
165
  export interface ExportRunResult {
@@ -165,16 +179,12 @@ export interface ExportRunResult {
165
179
  */
166
180
  export async function runExport(
167
181
  deps: BackupRunDeps,
168
- opts: { schema: string; stage: string; fingerprint: string },
182
+ opts: { schema: string; stage: string; fingerprint: string; id: string; key: string },
169
183
  ): Promise<ExportRunResult> {
170
184
  const { bucket, region, adminUrl } = deps;
171
- const { schema, stage, fingerprint } = opts;
185
+ const { schema, stage, fingerprint, id, key } = opts;
172
186
  const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
173
- const stamp = utcStamp(new Date());
174
- const shortId = randomBytes(3).toString('hex');
175
- const key = artifactKey(schema, stage, stamp, shortId);
176
187
  const mkey = metaKey(key);
177
- const id = artifactId(schema, stage, stamp, shortId);
178
188
 
179
189
  const s3 = await getS3(region);
180
190
  const { Upload } = await importOptionalAws(() => import('@aws-sdk/lib-storage'), '@aws-sdk/lib-storage', 'db:export');
@@ -219,6 +229,46 @@ export async function runExport(
219
229
  return { id, key, bytes, fingerprint };
220
230
  }
221
231
 
232
+ /**
233
+ * Fetch a backup's `.meta.json` manifest by id — the destructive-apply safety gate reads it to verify
234
+ * the ref. Returns null when the id is malformed or the manifest object is absent (a manifest exists
235
+ * ONLY on a successful dump, so absence == "no completed backup with that id"). The verify treats
236
+ * null as a refusal.
237
+ */
238
+ export async function fetchBackupManifest(deps: BackupRunDeps, id: string): Promise<BackupManifest | null> {
239
+ const key = keyForId(id);
240
+ if (!key) return null;
241
+ const s3 = await getS3(deps.region);
242
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
243
+ try {
244
+ const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: metaKey(key) }));
245
+ if (!obj.Body) return null;
246
+ const text = await obj.Body.transformToString();
247
+ return JSON.parse(text) as BackupManifest;
248
+ } catch {
249
+ return null; // NoSuchKey / parse failure → no valid manifest
250
+ }
251
+ }
252
+
253
+ /** The stage's newest backup manifest (for the gate's auto-resolve when no --snapshot-ref is given). */
254
+ export async function latestBackupManifest(deps: BackupRunDeps, stage: string): Promise<BackupManifest | null> {
255
+ const backups = await listBackups(deps, stage); // newest first
256
+ if (backups.length === 0) return null;
257
+ return fetchBackupManifest(deps, backups[0].id);
258
+ }
259
+
260
+ /**
261
+ * Delete a backup artifact + its `.meta.json` — used to retract a dump whose fingerprint could not be
262
+ * PROVEN stable across the dump window (a concurrent DDL), so the safety gate never trusts a manifest
263
+ * whose fp might not match the schema the dump actually holds.
264
+ */
265
+ export async function deleteBackup(deps: Pick<BackupRunDeps, 'bucket' | 'region'>, key: string): Promise<void> {
266
+ const s3 = await getS3(deps.region);
267
+ const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
268
+ await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: key })).catch(() => {});
269
+ await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: metaKey(key) })).catch(() => {});
270
+ }
271
+
222
272
  /** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
223
273
  export async function listBackups(deps: BackupRunDeps, stage: string): Promise<BackupSummary[]> {
224
274
  const s3 = await getS3(deps.region);
@@ -279,6 +329,77 @@ export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id:
279
329
  return { id, restored: true };
280
330
  }
281
331
 
332
+ // --- Restore intent IO (M2 durable ledger — an S3 object OUTSIDE the DB being replaced) ----------
333
+
334
+ /** Write the intent object (dispatcher, BEFORE RunTask). Crash-truthful: the record exists before
335
+ * the restore can trash the DB. */
336
+ export async function putRestoreIntent(
337
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
338
+ key: string,
339
+ intent: RestoreIntent,
340
+ ): Promise<void> {
341
+ const s3 = await getS3(deps.region);
342
+ const { PutObjectCommand } = await importOptionalAws(
343
+ () => import('@aws-sdk/client-s3'), '@aws-sdk/client-s3', 'db:restore',
344
+ );
345
+ await s3.send(new PutObjectCommand({
346
+ Bucket: deps.bucket, Key: key, Body: JSON.stringify(intent),
347
+ ContentType: 'application/json', ServerSideEncryption: 'AES256',
348
+ }));
349
+ }
350
+
351
+ /** Read an intent object, or null when absent/unparseable. */
352
+ export async function getRestoreIntent(
353
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
354
+ key: string,
355
+ ): Promise<RestoreIntent | null> {
356
+ const s3 = await getS3(deps.region);
357
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
358
+ try {
359
+ const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
360
+ if (!obj.Body) return null;
361
+ return JSON.parse(await obj.Body.transformToString()) as RestoreIntent;
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ /** Merge a patch into an intent object (stamp the ARN, or the terminal outcome). Best-effort. */
368
+ export async function stampRestoreIntent(
369
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
370
+ key: string,
371
+ patch: Partial<RestoreIntent>,
372
+ ): Promise<void> {
373
+ const current = await getRestoreIntent(deps, key);
374
+ if (!current) return;
375
+ await putRestoreIntent(deps, key, { ...current, ...patch });
376
+ }
377
+
378
+ /** List a stage's restore intents (newest first) — the durable half of db:restores. */
379
+ export async function listRestoreIntents(
380
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'>,
381
+ stage: string,
382
+ ): Promise<RestoreIntent[]> {
383
+ const s3 = await getS3(deps.region);
384
+ const { ListObjectsV2Command, GetObjectCommand } = await import('@aws-sdk/client-s3');
385
+ const prefix = `${restoreIntentPrefix(stage)}/`;
386
+ const out: RestoreIntent[] = [];
387
+ let token: string | undefined;
388
+ do {
389
+ const res: any = await s3.send(new ListObjectsV2Command({ Bucket: deps.bucket, Prefix: prefix, ContinuationToken: token }));
390
+ for (const o of res.Contents ?? []) {
391
+ if (!o.Key?.endsWith('.json')) continue;
392
+ try {
393
+ const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: o.Key }));
394
+ if (obj.Body) out.push(JSON.parse(await obj.Body.transformToString()) as RestoreIntent);
395
+ } catch { /* skip unreadable */ }
396
+ }
397
+ token = res.IsTruncated ? res.NextContinuationToken : undefined;
398
+ } while (token);
399
+ out.sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
400
+ return out;
401
+ }
402
+
282
403
  /** The URL mode's precondition — a presigned http(s) download, never a path or bucket ref. */
283
404
  export function assertRestoreUrl(url: string): void {
284
405
  if (!/^https?:\/\//.test(url)) {
package/src/backup.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  /**
2
- * db:backup — logical pg_dump/pg_restore helpers (server side).
3
- *
4
- * The version gate is load-bearing: pg_dump must be >= the server's MAJOR version, and there is no
5
- * bypass flag (a newer server refuses an older client; a custom-format archive won't restore under
6
- * an older pg_restore). So before any dump we compare the layer's pg_dump major against the live
7
- * server's `server_version_num`. The parsing/compat logic is pure and unit-tested here; the actual
8
- * spawn (which needs the pg_dump layer at runtime) is exercised by the deployed db:backup:probe.
9
- * See docs/plans/db-backup-restore.md.
2
+ * db:backup — logical pg_dump/pg_restore helpers (server side). Pure helpers: the S3 key schemes,
3
+ * the PG* env decomposition, the pg_dump/pg_restore arg builders, the snapshot-ref safety gate, and
4
+ * the version-compat math. The pg binaries run in the ephemeral Fargate Task lane (backup-run.ts),
5
+ * not this Lambda; the version gate (pg_dump major >= server major — no bypass) is enforced by the
6
+ * Task image's handshake, verified on demand with `everystack task:probe`.
10
7
  */
11
8
 
12
9
  /**
@@ -110,6 +107,135 @@ export function artifactIdForKey(dumpKey: string): string | null {
110
107
  return m ? `${m[1]}/${m[2]}/${m[3]}` : null;
111
108
  }
112
109
 
110
+ // --- Mint targets (dispatcher-side) -------------------------------------------------------------
111
+ // The ops Lambda mints the id+key from ITS clock + a random shortId and hands both to the ephemeral
112
+ // task (via the job) AND back to the CLI at dispatch time — so the CLI knows the artifact the moment
113
+ // dispatch returns, with no result-readback race. The task writes to exactly this key; nothing has
114
+ // to be discovered after the fact.
115
+
116
+ /** A minted artifact/backup target: the stage/schema-qualified id and the S3 dump key it maps to. */
117
+ export interface MintedTarget {
118
+ id: string;
119
+ key: string;
120
+ }
121
+
122
+ /** Mint a full-database backup target (db:backup). `now` is the dispatcher's clock. */
123
+ export function mintBackupTarget(stage: string, now: Date, shortId: string): MintedTarget {
124
+ const stamp = utcStamp(now);
125
+ return { id: backupId(stage, stamp, shortId), key: backupKey(stage, stamp, shortId) };
126
+ }
127
+
128
+ /** Mint a schema-scoped artifact target (db:export). `now` is the dispatcher's clock. */
129
+ export function mintExportTarget(schema: string, stage: string, now: Date, shortId: string): MintedTarget {
130
+ const stamp = utcStamp(now);
131
+ return { id: artifactId(schema, stage, stamp, shortId), key: artifactKey(schema, stage, stamp, shortId) };
132
+ }
133
+
134
+ // --- Snapshot-ref verification (the destructive-apply safety gate) ------------------------------
135
+ // A destructive db:apply must be covered by a COMPLETED, restorable, matching backup. The ops Lambda
136
+ // resolves the ref (an explicit --snapshot-ref id, else the stage's newest backup) and runs this pure
137
+ // verdict. The .meta.json is written ONLY on a successful dump (runBackup deletes the partial + never
138
+ // writes meta on failure), so a resolvable manifest IS the success proof. The fingerprint tie
139
+ // (manifest.fingerprint == plan.from) makes the guarantee provable, not merely temporal.
140
+
141
+ /** A backup's `.meta.json` — the manifest the safety gate reads. `fingerprint` added for the fp tie. */
142
+ export interface BackupManifest {
143
+ id: string;
144
+ stage: string;
145
+ /** ISO 8601. */
146
+ createdAt: string;
147
+ /** The base-schema fingerprint captured at dump time (absent on pre-B3 backups). */
148
+ fingerprint?: string;
149
+ bytes?: number;
150
+ key: string;
151
+ }
152
+
153
+ export interface SnapshotVerifyInput {
154
+ /** The resolved backup manifest, or null when none could be resolved. */
155
+ manifest: BackupManifest | null;
156
+ /** plan.fromFingerprint — the schema state the apply expects to change. */
157
+ planFrom: string;
158
+ /** The stage being changed — the backup must cover THIS database. */
159
+ targetStage: string;
160
+ now: Date;
161
+ /** How old a backup may be and still qualify (data-rollback bound; fp already proves schema). */
162
+ freshnessWindowMs: number;
163
+ }
164
+
165
+ export type SnapshotVerdict = { ok: true; id: string } | { ok: false; reason: string };
166
+
167
+ /**
168
+ * Verify a resolved backup is a valid safety net for a destructive apply: it exists (⇒ succeeded),
169
+ * covers this stage, carries a fingerprint that equals plan.from (⇒ restores the exact state being
170
+ * changed), and is fresh. Every refusal names the fix (db:backup). Pure; the resolution IO lives in
171
+ * backup-run.ts and the enforcement in the ops db:apply action.
172
+ */
173
+ export function verifySnapshotRef(input: SnapshotVerifyInput): SnapshotVerdict {
174
+ const { manifest, planFrom, targetStage, now, freshnessWindowMs } = input;
175
+ const takeFresh = `take a fresh backup: everystack db:backup --stage ${targetStage}, then re-run.`;
176
+ if (!manifest) {
177
+ return { ok: false, reason: `no backup found for stage ${targetStage} — ${takeFresh}` };
178
+ }
179
+ if (manifest.stage !== targetStage) {
180
+ return { ok: false, reason: `snapshot ${manifest.id} is for stage ${manifest.stage}, not ${targetStage} — a snapshot must cover the database being changed. ${takeFresh}` };
181
+ }
182
+ if (!manifest.fingerprint) {
183
+ return { ok: false, reason: `snapshot ${manifest.id} predates fingerprint stamping — ${takeFresh}` };
184
+ }
185
+ if (manifest.fingerprint !== planFrom) {
186
+ return { ok: false, reason: `snapshot ${manifest.id} captured schema ${manifest.fingerprint.slice(0, 12)}, but this plan starts from ${planFrom.slice(0, 12)} — it would not restore the state being changed. ${takeFresh}` };
187
+ }
188
+ const ageMs = now.getTime() - Date.parse(manifest.createdAt);
189
+ if (!(ageMs >= 0) || ageMs > freshnessWindowMs) {
190
+ const mins = Math.round(ageMs / 60_000);
191
+ return { ok: false, reason: `snapshot ${manifest.id} is ${mins} min old (freshness window ${Math.round(freshnessWindowMs / 60_000)} min) — ${takeFresh}` };
192
+ }
193
+ return { ok: true, id: manifest.id };
194
+ }
195
+
196
+ // --- Restore intent (M2: the durable ledger for a DB-REPLACING restore) -------------------------
197
+ // A restore pg_restores a full backup with --clean, drop+recreating the app schemas — so its own
198
+ // audit record is written OUTSIDE the DB, as a durable S3 object under `restores/<stage>/`, by the
199
+ // dispatcher BEFORE RunTask. That guarantees forensics regardless of how the restore leaves the DB
200
+ // (partial, corrupt, or a hard-killed task): "a restore of X was attempted on Y at Z" always
201
+ // survives. (The everystack ops schema itself is excluded from the dump, so its own ledgers persist
202
+ // across a restore — but the restore's record still belongs in S3, not in the DB it's rewriting.)
203
+ // db:restores reads these + the live ECS status.
204
+
205
+ /** The S3 prefix a stage's restore intents live under (admin-only, encrypted; OUTSIDE the app DB). */
206
+ export function restoreIntentPrefix(stage: string): string {
207
+ return `restores/${stage}`;
208
+ }
209
+
210
+ /** The S3 object key for a restore intent. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string. */
211
+ export function restoreIntentKey(stage: string, stamp: string, shortId: string): string {
212
+ return `${restoreIntentPrefix(stage)}/${stamp}-${shortId}.json`;
213
+ }
214
+
215
+ /** A restore intent's stage-qualified id (what db:restores displays): `dev/20260723T120000Z-ab12cd`. */
216
+ export function restoreIntentId(stage: string, stamp: string, shortId: string): string {
217
+ return `${stage}/${stamp}-${shortId}`;
218
+ }
219
+
220
+ /** Mint a restore intent target (dispatcher-side): the S3 key + the display id. */
221
+ export function mintRestoreIntent(stage: string, now: Date, shortId: string): { intentKey: string; intentId: string } {
222
+ const stamp = utcStamp(now);
223
+ return { intentKey: restoreIntentKey(stage, stamp, shortId), intentId: restoreIntentId(stage, stamp, shortId) };
224
+ }
225
+
226
+ /** The M2 intent record — the crash-truthful "what was attempted", plus the outcome once known. */
227
+ export interface RestoreIntent {
228
+ intentId: string;
229
+ stage: string;
230
+ /** The restore source: a backup id in-stage, or a presigned cross-stage URL (never both). */
231
+ source: { id?: string; url?: string };
232
+ actor?: string | null;
233
+ startedAt: string;
234
+ taskArn?: string;
235
+ status?: 'started' | 'running' | 'succeeded' | 'failed';
236
+ finishedAt?: string;
237
+ }
238
+
113
239
  /** Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp (caller's clock; pure). */
114
240
  export function utcStamp(date: Date): string {
115
241
  const p = (n: number): string => String(n).padStart(2, '0');
@@ -155,9 +281,18 @@ export function dbNameFromUrl(url: string): string {
155
281
  */
156
282
  export function pgDumpArgs(opts: { schema?: string } = {}): string[] {
157
283
  const args = ['-Fc', '--no-owner', '--no-privileges', '--no-comments'];
158
- // A schema-scoped artifact dumps ONE schema (the canonical-sync export). pg_dump's --schema
159
- // takes a pattern; a plain identifier scopes to exactly that schema and its objects.
160
- if (opts.schema) args.push(`--schema=${opts.schema}`);
284
+ if (opts.schema) {
285
+ // A schema-scoped artifact dumps ONE schema (the canonical-sync export). pg_dump's --schema
286
+ // takes a pattern; a plain identifier scopes to exactly that schema and its objects.
287
+ args.push(`--schema=${opts.schema}`);
288
+ } else {
289
+ // A full backup EXCLUDES the framework ops schema: everystack.* holds the OPERATIONAL ledgers
290
+ // (task_log, schema_log, exec_log, backfill_log, derived_provenance) — the TARGET's audit and
291
+ // migration history, not app data. Restoring them would rewind that history AND makes
292
+ // pg_restore --clean fail (DROP SCHEMA everystack can't run: its ledger tables depend on it,
293
+ // non-CASCADE). Excluding it keeps a restore to app data and preserves the target's ops history.
294
+ args.push('--exclude-schema=everystack');
295
+ }
161
296
  return args;
162
297
  }
163
298
 
@@ -168,24 +303,3 @@ export function pgDumpArgs(opts: { schema?: string } = {}): string[] {
168
303
  export function pgRestoreArgs(dbName: string): string[] {
169
304
  return ['--clean', '--if-exists', '--no-owner', '--no-privileges', '--no-comments', '-d', dbName];
170
305
  }
171
-
172
- /**
173
- * Spawn `<bin> --version` and return its trimmed stdout (e.g. "pg_dump (PostgreSQL) 16.9").
174
- * Rejects when the binary is missing — which, in the ops Lambda, means the pg_dump layer is not
175
- * attached. `bin` defaults to `pg_dump`, resolved on PATH (the layer lands at /opt/bin, on PATH).
176
- */
177
- export async function pgClientVersion(bin = 'pg_dump'): Promise<string> {
178
- const { spawn } = await import('node:child_process');
179
- return await new Promise<string>((resolve, reject) => {
180
- let out = '';
181
- let err = '';
182
- const child = spawn(bin, ['--version']);
183
- child.stdout.on('data', (d) => { out += d.toString(); });
184
- child.stderr.on('data', (d) => { err += d.toString(); });
185
- child.on('error', (e) => reject(e));
186
- child.on('close', (code) => {
187
- if (code === 0) resolve(out.trim());
188
- else reject(new Error(`${bin} --version exited ${code}: ${err.trim()}`));
189
- });
190
- });
191
- }
package/src/db-doctor.ts CHANGED
@@ -87,20 +87,6 @@ export interface DoctorProbe {
87
87
  * an older probe (the check is then skipped). B4.
88
88
  */
89
89
  darkRelations?: DarkRelation[];
90
- /**
91
- * pg_dump layer capability: whether @everystack/pg-tools is attached (pg_dump present) and its
92
- * major can dump this server (client >= server). Drives the one-line "backups" lens — a missing
93
- * layer blocks db:backup AND the snapshot-first destructive apply. Absent on an older probe (the
94
- * check is then skipped). See docs/plans/pgdump-layer.md.
95
- */
96
- backup?: {
97
- /** `pg_dump --version` output, or null when the layer is not attached. */
98
- pgDump: string | null;
99
- pgDumpMajor: number | null;
100
- serverMajor: number | null;
101
- /** pgDumpMajor >= serverMajor — a client can dump an older-or-equal server, never a newer one. */
102
- compatible: boolean;
103
- };
104
90
  }
105
91
 
106
92
  /** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
@@ -558,32 +544,8 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
558
544
  }
559
545
  }
560
546
 
561
- // Backups lens — the pg_dump layer gates db:backup AND the snapshot-first destructive apply. A
562
- // missing layer isn't a security hole (warn, not fail), but it silently blocks the ceremony.
563
- if (probe.backup) {
564
- const b = probe.backup;
565
- if (b.pgDump && b.compatible) {
566
- checks.push({
567
- name: 'Logical backups available (pg_dump layer)',
568
- status: 'pass',
569
- detail: `pg_dump major ${b.pgDumpMajor} attached and compatible with server major ${b.serverMajor} — db:backup and the snapshot-first destructive apply are ready`,
570
- });
571
- } else if (b.pgDump && !b.compatible) {
572
- checks.push({
573
- name: 'Logical backups available (pg_dump layer)',
574
- status: 'warn',
575
- detail: `pg_dump major ${b.pgDumpMajor} is older than server major ${b.serverMajor} — it will refuse to dump (no bypass exists)`,
576
- remediation: 'Upgrade @everystack/pg-tools to advance the client ceiling to >= the server major, then redeploy.',
577
- });
578
- } else {
579
- checks.push({
580
- name: 'Logical backups available (pg_dump layer)',
581
- status: 'warn',
582
- detail: 'pg_dump layer not attached — db:backup and the snapshot-first destructive apply are unavailable',
583
- remediation: 'Install @everystack/pg-tools and redeploy (pgDumpLayer attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts.',
584
- });
585
- }
586
- }
547
+ // (No backups lens: pg-binary backup/restore run in the ephemeral Fargate Task lane, not the ops
548
+ // Lambda. Verify the Task image's pg_dump-vs-server compatibility with `everystack task:probe`.)
587
549
 
588
550
  return {
589
551
  api,
@@ -610,9 +572,6 @@ export interface DoctorProbeRunners {
610
572
  export interface RunDoctorProbeOptions extends DoctorReportOptions {
611
573
  /** Names the api credential source in the connect-failure message (e.g. 'DATABASE_URL'). */
612
574
  apiSourceLabel?: string;
613
- /** The backups lens (pg_dump layer) probe — Lambda-only. Omitted on the direct venue,
614
- * where there is no layer concept, so the lens is simply not shown. */
615
- backup?: () => Promise<DoctorProbe['backup']>;
616
575
  }
617
576
 
618
577
  const quoteDoctorIdent = (s: string): string => '"' + String(s).replace(/"/g, '""') + '"';
@@ -667,10 +626,8 @@ export async function runDoctorProbe(
667
626
  }
668
627
  }
669
628
 
670
- const backup = options.backup ? await options.backup() : undefined;
671
-
672
629
  return buildDoctorReport(
673
- { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations, backup },
630
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
674
631
  { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
675
632
  );
676
633
  }
package/src/plugin.ts CHANGED
@@ -729,34 +729,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
729
729
  const apiRun = async (q: string): Promise<any[]> => rowsOf(await apiDb.execute(sql.raw(q)));
730
730
  const opsRun = async (q: string): Promise<any[]> => rowsOf(await opsDb.execute(sql.raw(q)));
731
731
 
732
- // Backups lens — is the pg_dump layer attached AND version-compatible? Lambda-only:
733
- // a missing layer leaves pgDump null (the lens warns), never throws. Absent on the
734
- // CLI direct venue, where there is no layer.
735
- const backup = async (): Promise<import('./db-doctor').DoctorProbe['backup']> => {
736
- try {
737
- const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
738
- let pgDump: string | null = null;
739
- let pgDumpMajor: number | null = null;
740
- try {
741
- pgDump = await pgClientVersion('pg_dump');
742
- pgDumpMajor = parsePgDumpMajor(pgDump);
743
- } catch {
744
- // layer absent — pgDump stays null, the lens reports "not attached"
745
- }
746
- const svnRow = rowsOf(await opsDb.execute(sql.raw('SHOW server_version_num')))[0];
747
- const svn = Number(svnRow?.server_version_num);
748
- const serverMajor = Number.isFinite(svn) ? serverMajorFromNum(svn) : null;
749
- return {
750
- pgDump,
751
- pgDumpMajor,
752
- serverMajor,
753
- compatible: pgDumpMajor != null && Number.isFinite(svn) ? isDumpCompatible(pgDumpMajor, svn) : false,
754
- };
755
- } catch {
756
- return undefined; // leave backup out → the lens is skipped, like the other optional probes
757
- }
758
- };
759
-
732
+ // No backups lens: pg-binary work runs in the ephemeral Fargate Task lane, not the ops Lambda.
733
+ // Verify the Task image's pg_dump-vs-server compatibility with `everystack task:probe`.
760
734
  try {
761
735
  return await runDoctorProbe(
762
736
  { api: apiRun, ops: opsRun },
@@ -764,7 +738,6 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
764
738
  apiSourceLabel: 'DATABASE_URL',
765
739
  forceRlsCarveouts: options.forceRlsCarveouts,
766
740
  functionSchemas: options.functionSchemas,
767
- backup,
768
741
  },
769
742
  );
770
743
  } catch (err: any) {
@@ -820,55 +793,13 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
820
793
  );
821
794
  };
822
795
 
823
- // --- db:backup:probe — is the pg_dump layer attached, and does it match the server? ---
824
- // The version gate for logical backups: pg_dump must be >= the server's MAJOR (no bypass flag
825
- // exists). Returns the layer's pg_dump version + the live server_version_num so a deploy can
826
- // confirm the layer is present AND version-compatible before db:backup is trusted. See backup.ts.
827
- actions['db:backup:probe'] = async () => {
828
- const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
829
- const { sql } = await import('drizzle-orm');
830
-
831
- let pgDump: string;
832
- try {
833
- pgDump = await pgClientVersion('pg_dump');
834
- } catch (err: any) {
835
- return { error: `pg_dump not available — the pg_dump layer is not attached. Install @everystack/pg-tools and redeploy (pgDumpLayer() attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts. (${err?.message || String(err)})` };
836
- }
837
- const pgDumpMajor = parsePgDumpMajor(pgDump);
838
-
839
- const rows: any = await opsDb.execute(sql.raw('SHOW server_version_num'));
840
- const row = (Array.isArray(rows) ? rows : rows?.rows ?? [])[0];
841
- const serverVersionNum = Number(row?.server_version_num);
842
- const serverMajor = Number.isFinite(serverVersionNum) ? serverMajorFromNum(serverVersionNum) : null;
843
-
844
- return {
845
- pgDump,
846
- pgDumpMajor,
847
- serverVersionNum: Number.isFinite(serverVersionNum) ? serverVersionNum : null,
848
- serverMajor,
849
- compatible: pgDumpMajor != null && Number.isFinite(serverVersionNum)
850
- ? isDumpCompatible(pgDumpMajor, serverVersionNum)
851
- : false,
852
- };
853
- };
854
796
 
855
- // --- db:backup / db:backups / db:restore — logical pg_dump backups streamed to S3 ---
856
- // The operator (privileged) connection drives pg_dump/pg_restore; the dump streams to the
857
- // dedicated private backups bucket. Restore is destructive and requires an explicit confirm in
858
- // the payload (the CLI sets it after the user confirms). See backup-run.ts.
797
+ // --- db:backups / db:restore — list + restore logical backups ---
798
+ // db:backup and db:export are pg_dump work and now run in the ephemeral Task lane
799
+ // (@everystack/jobs dbTaskPlugin) — the ops Lambda holds no pg binaries. db:backups is a plain
800
+ // S3 list; db:restore drives pg_restore in-Lambda until Phase 3b moves it to the Task. Restore is
801
+ // destructive and requires an explicit confirm in the payload (the CLI sets it). See backup-run.ts.
859
802
  {
860
- actions['db:backup'] = async (payload, ctx) => {
861
- const r = await resolveBackupDeps();
862
- if ('error' in r) return { error: r.error };
863
- const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
864
- const { runBackup } = await import('./backup-run');
865
- try {
866
- return await runBackup(r.deps, stage);
867
- } catch (err: any) {
868
- return { error: err?.message || String(err) };
869
- }
870
- };
871
-
872
803
  actions['db:backups'] = async (payload, ctx) => {
873
804
  const r = await resolveBackupDeps();
874
805
  if ('error' in r) return { error: r.error };
@@ -881,40 +812,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
881
812
  }
882
813
  };
883
814
 
884
- actions['db:restore'] = async (payload, _ctx) => {
885
- const { id, url, confirm } = (payload ?? {}) as { id?: string; url?: string; confirm?: boolean };
886
- if (!id && !url) return { error: "db:restore requires { id } (a backup in this stage's bucket) or { url } (a presigned cross-stage hand-off — db:fork mints one)" };
887
- if (id && url) return { error: 'db:restore takes { id } or { url }, not both' };
888
- if (confirm !== true) {
889
- return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
890
- }
891
- const r = await resolveBackupDeps();
892
- if ('error' in r) return { error: r.error };
893
- const { runRestore, runRestoreFromUrl } = await import('./backup-run');
894
- try {
895
- return url ? await runRestoreFromUrl(r.deps, url) : await runRestore(r.deps, id!);
896
- } catch (err: any) {
897
- return { error: err?.message || String(err) };
898
- }
899
- };
900
-
901
- // --- db:export — the schema-scoped, fingerprint-stamped artifact (canonical-sync gap B) ---
902
- // pg_dump ONE schema → S3 artifact carrying the schema fingerprint the CLI computed from the
903
- // same live schema (one formula both sides). db:swap gates on that fingerprint before it lands.
904
- actions['db:export'] = async (payload, ctx) => {
905
- const { schema, fingerprint } = (payload ?? {}) as { schema?: string; fingerprint?: string };
906
- const stage = ((payload ?? {}) as { stage?: string }).stage ?? ctx.environment ?? 'default';
907
- if (!schema) return { error: 'db:export requires { schema }' };
908
- if (!fingerprint) return { error: 'db:export requires { fingerprint } — the CLI computes the scoped schema fingerprint so the artifact and the swap gate agree' };
909
- const r = await resolveBackupDeps();
910
- if ('error' in r) return { error: r.error };
911
- const { runExport } = await import('./backup-run');
912
- try {
913
- return await runExport(r.deps, { schema, stage, fingerprint });
914
- } catch (err: any) {
915
- return { error: err?.message || String(err) };
916
- }
917
- };
815
+ // db:restore moved to the ephemeral Task lane (@everystack/jobs dbTaskPlugin) — it runs
816
+ // pg_restore, and the ops Lambda holds no pg binaries. Its intent is a durable S3 object (M2),
817
+ // since a restore replaces the DB task_log lives in.
918
818
  }
919
819
 
920
820
  // --- db:apply — the credential-free staged write (Phase 2) ---
@@ -928,7 +828,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
928
828
  // direct --database-url path runs, only here the connection never leaves the
929
829
  // Lambda. A destructive plan snapshots first (db:backup), in order.
930
830
  actions['db:apply'] = async (payload, ctx) => {
931
- const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage } =
831
+ const { plan, actor, gitRef, descentVerdict, authorityVerdict, stage, snapshotRef } =
932
832
  (payload ?? {}) as {
933
833
  plan?: any;
934
834
  actor?: string | null;
@@ -936,6 +836,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
936
836
  descentVerdict?: { ok: boolean; reason?: string };
937
837
  authorityVerdict?: { ok: boolean; reason?: string };
938
838
  stage?: string;
839
+ /** An explicit backup id to verify; absent → the gate auto-resolves the stage's newest. */
840
+ snapshotRef?: string;
939
841
  };
940
842
  if (!plan || typeof plan !== 'object' || !Array.isArray(plan.statements)) {
941
843
  return { error: 'db:apply requires { plan } — a reviewed edge minted by db:plan' };
@@ -952,6 +854,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
952
854
  };
953
855
 
954
856
  const destructive = Number(plan.destructive) > 0;
857
+ const snapStage = stage ?? ctx.environment ?? 'default';
858
+ // How old a backup may be and still auto-qualify as the safety net (the fp match already proves
859
+ // schema identity; this only bounds how much data a restore would roll back).
860
+ const SNAPSHOT_FRESHNESS_MS = 60 * 60_000;
955
861
  let snapshotId: string | undefined;
956
862
  try {
957
863
  const result = await executeApplyPlan(runner, plan, {
@@ -965,19 +871,27 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
965
871
  ...(destructive && authorityVerdict
966
872
  ? { verifyAuthority: async () => (authorityVerdict.ok ? { ok: true as const } : { ok: false as const, reason: authorityVerdict.reason ?? 'authority refused' }) }
967
873
  : {}),
968
- // Losing data is hard: the snapshot runs HERE, after the lock/descent/
969
- // authority gates pass and before the DDL. It uses the same logical
970
- // backup as db:backup, on the operator connection.
874
+ // The B3 safety gate: a destructive apply must be covered by a VERIFIED backup — the apply
875
+ // no longer takes its own. Resolve the ref (explicit id, else the stage's newest) and verify
876
+ // it SERVER-SIDE (never a CLI-attested boolean): succeeded, this stage, fresh, and
877
+ // fingerprint == plan.from. A miss is recorded in schema_log by executeApplyPlan.
971
878
  ...(destructive
972
879
  ? {
973
- beforeDestructive: async () => {
880
+ verifySnapshot: async ({ planFrom }: { planFrom: string }) => {
974
881
  const r = await resolveBackupDeps();
975
- if ('error' in r) {
976
- throw new Error(`snapshot before a destructive apply is impossible — ${r.error}`);
977
- }
978
- const { runBackup } = await import('./backup-run');
979
- const backup: any = await runBackup(r.deps, stage ?? ctx.environment ?? 'default');
980
- snapshotId = backup?.id;
882
+ if ('error' in r) return { ok: false as const, reason: `snapshot gate: ${r.error}` };
883
+ const { fetchBackupManifest, latestBackupManifest } = await import('./backup-run');
884
+ const { verifySnapshotRef } = await import('./backup');
885
+ const manifest = snapshotRef
886
+ ? await fetchBackupManifest(r.deps, snapshotRef)
887
+ : await latestBackupManifest(r.deps, snapStage);
888
+ const verdict = verifySnapshotRef({
889
+ manifest, planFrom, targetStage: snapStage,
890
+ now: new Date(), freshnessWindowMs: SNAPSHOT_FRESHNESS_MS,
891
+ });
892
+ if (!verdict.ok) return { ok: false as const, reason: verdict.reason };
893
+ snapshotId = verdict.id;
894
+ return { ok: true as const };
981
895
  },
982
896
  }
983
897
  : {}),
@@ -1089,6 +1003,68 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
1089
1003
  }
1090
1004
  };
1091
1005
 
1006
+ // --- db:exec — credential-free DML-only write SQL, ONE transaction, catalog-digest guarded ---
1007
+ // The daily driver: `psql < file.sql --stage`, but credential-free. It runs on the SAME ops
1008
+ // connection db:seed uses (opsDb) — no new role, no BYPASSRLS, no superuser. The file runs in
1009
+ // one transaction; a catalog digest taken BEFORE vs AFTER (schema-relevant catalogs, app
1010
+ // schemas) rejects ANY schema change — however caused (a function, a DO block, dynamic SQL) —
1011
+ // by rolling the whole txn back. The ledger (everystack.exec_log) is crash-truthful: the
1012
+ // intent row commits on its own, BEFORE the run, so a mid-flight death still records what was
1013
+ // attempted; the outcome (status + per-statement rows-affected) is written after.
1014
+ actions['db:exec'] = async (payload, _ctx) => {
1015
+ const p = (payload ?? {}) as { sql?: string; actor?: string | null; stage?: string | null };
1016
+ if (!p.sql || typeof p.sql !== 'string') {
1017
+ return { error: 'db:exec needs a SQL file body (payload.sql).' };
1018
+ }
1019
+ const fileSql = p.sql;
1020
+ const schemas = options.schemas ?? ['public'];
1021
+ try {
1022
+ const {
1023
+ executeExec, runExecInTx,
1024
+ ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate,
1025
+ } = await import('@everystack/cli/exec');
1026
+ const { sql } = await import('drizzle-orm');
1027
+
1028
+ // The ledger writes on the ops connection but OUTSIDE the file's transaction (each
1029
+ // execute auto-commits), so the intent row survives a rollback or a mid-flight death.
1030
+ const audit = async (query: string): Promise<any[]> => {
1031
+ const result: any = await opsDb.execute(sql.raw(query));
1032
+ return Array.isArray(result) ? result : result?.rows ?? [];
1033
+ };
1034
+
1035
+ return await executeExec({
1036
+ sql: fileSql,
1037
+ actor: p.actor ?? 'unknown',
1038
+ stage: p.stage ?? 'unknown',
1039
+ writeIntent: async (intent) => {
1040
+ for (const ddl of ENSURE_EXEC_LOG_SQL) await audit(ddl);
1041
+ const rows = await audit(renderExecIntentInsert({ sha: intent.sha, actor: intent.actor, stage: intent.stage }));
1042
+ return String(rows[0].id);
1043
+ },
1044
+ run: () =>
1045
+ opsDb.transaction(async (tx: any) => {
1046
+ const txRun = async (stmt: string) => {
1047
+ const r: any = await tx.execute(sql.raw(stmt));
1048
+ const rows = Array.isArray(r) ? r : r?.rows ?? [];
1049
+ const count =
1050
+ typeof r?.count === 'number' ? r.count
1051
+ : typeof r?.rowCount === 'number' ? r.rowCount
1052
+ : rows.length;
1053
+ return { rows, count };
1054
+ };
1055
+ return runExecInTx(txRun, fileSql, { schemas });
1056
+ }),
1057
+ writeOutcome: async (id, outcome) => {
1058
+ await audit(renderExecOutcomeUpdate(id, outcome));
1059
+ },
1060
+ });
1061
+ } catch (err: any) {
1062
+ // A DML-only violation (SchemaChangedError) is a clean rejection — the txn already rolled
1063
+ // back and the outcome is recorded 'failed'. Surface the message, don't crash the plane.
1064
+ return { error: err?.message || String(err) };
1065
+ }
1066
+ };
1067
+
1092
1068
  // --- db:refresh — the credential-free DATA lane's refresh half (the reconcile-lane twin) ---
1093
1069
  // db:reconcile deploys the derived DEFINITION but no-ops on an unchanged def, so a pure
1094
1070
  // DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
package/src/ssr.ts CHANGED
@@ -69,6 +69,15 @@ export interface WebHandlerOptions {
69
69
  /** Inject __EXPO_ROUTER_HYDRATE__ flag before </head>. Default: true. */
70
70
  injectHydrateFlag?: boolean;
71
71
 
72
+ /**
73
+ * Opaque the expo-router loader-data blob so search crawlers can't harvest
74
+ * URL-shaped strings (route keys like `/stories/index`, image/link values)
75
+ * out of the inline hydration script. Default: true. Runtime behaviour is
76
+ * unchanged — the decoded globalThis value is byte-identical. See
77
+ * deharvestLoaderData.
78
+ */
79
+ deharvestLoaderData?: boolean;
80
+
72
81
  /**
73
82
  * App-specific HTML post-processing. Called after built-in processing
74
83
  * (param stripping, hydrate flag). Receives the processed HTML and the
@@ -82,6 +91,59 @@ export interface WebHandlerOptions {
82
91
  const DEFAULT_STRIP_PARAMS = ['_v', '_a'];
83
92
  const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
84
93
 
94
+ /**
95
+ * Matches expo-router's `<PreloadedDataScript />` output. The renderer emits
96
+ * exactly:
97
+ *
98
+ * <script id="expo-router-data" type="module">
99
+ * globalThis.__EXPO_ROUTER_LOADER_DATA__ = JSON.parse("<escaped-json>");
100
+ * </script>
101
+ *
102
+ * Group 1 = the opening tag, group 2 = the double-quoted JS string literal
103
+ * argument. The literal is HTML-safe (expo escapes <, >, & to \uXXXX), so a
104
+ * `</script>` terminator never appears inside it.
105
+ */
106
+ const LOADER_DATA_SCRIPT_RE =
107
+ /(<script id="expo-router-data"[^>]*>globalThis\.__EXPO_ROUTER_LOADER_DATA__\s*=\s*JSON\.parse\()("(?:[^"\\]|\\.)*")(\);<\/script>)/;
108
+
109
+ /**
110
+ * Rewrite the expo-router loader-data script so its payload is opaque in the
111
+ * raw HTML while the executed value is byte-identical.
112
+ *
113
+ * expo-router keys the loader map by the route FILE path, so index routes get a
114
+ * `/index` suffix (`/stories/index`). Googlebot harvests URL-shaped strings
115
+ * from inline JS, resolves those same-origin, crawls `/stories/index`, and
116
+ * 404s. The key cannot be renamed: expo-router's client resolver
117
+ * (useLoaderData) looks the data up by the identical `/index` key, so any
118
+ * SSR-only rename mismatches on hydration (white flash).
119
+ *
120
+ * The one server-only fix that removes the harvestable text AND preserves the
121
+ * exact key: base64-encode the JSON payload. No URL-shaped substring survives
122
+ * in the source for the crawler, and at runtime `atob` + TextDecoder + JSON.parse
123
+ * reproduces the same object with the same keys — the client is untouched. This
124
+ * also opaques URL-shaped *values* (image/CDN links inside loader data), killing
125
+ * the whole harvest class, not just `/index` keys.
126
+ *
127
+ * Fail-safe: if the script is absent or doesn't match the expected shape (e.g.
128
+ * an expo-router format change), the HTML is returned unchanged. Idempotent —
129
+ * the rewritten script no longer matches the string-literal form.
130
+ */
131
+ export function deharvestLoaderData(html: string): string {
132
+ return html.replace(LOADER_DATA_SCRIPT_RE, (full, open: string, literal: string, close: string) => {
133
+ let jsonText: string;
134
+ try {
135
+ // The literal is valid JSON string syntax; parsing yields the JSON text
136
+ // the client would otherwise receive verbatim.
137
+ jsonText = JSON.parse(literal) as string;
138
+ } catch {
139
+ return full; // unexpected shape — leave untouched
140
+ }
141
+ const b64 = Buffer.from(jsonText, 'utf8').toString('base64');
142
+ const decode = `new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(b64)}),c=>c.charCodeAt(0)))`;
143
+ return `${open}${decode}${close}`;
144
+ });
145
+ }
146
+
85
147
  /**
86
148
  * Wraps an SSR request handler with framework-level fixes:
87
149
  *
@@ -91,7 +153,10 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
91
153
  * Native Web uses `hydrateRoot()` instead of `createRoot()`.
92
154
  * (React 19 suppresses <script> tags from SSR output, so this must
93
155
  * be done via string replacement after rendering.)
94
- * 3. Calls an optional `postProcessHtml` hook for app-specific transforms
156
+ * 3. Opaques the expo-router loader-data blob so crawlers can't harvest its
157
+ * URL-shaped route keys (`/{path}/index`) and 404 on them. See
158
+ * deharvestLoaderData.
159
+ * 4. Calls an optional `postProcessHtml` hook for app-specific transforms
95
160
  * (e.g., JSON-LD injection, status code override from meta tags).
96
161
  *
97
162
  * @param innerHandler - The expo-server request handler
@@ -100,11 +165,12 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
100
165
  */
101
166
  export function wrapSsrHandler(
102
167
  innerHandler: (request: Request) => Promise<Response>,
103
- options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'postProcessHtml'>,
168
+ options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'deharvestLoaderData' | 'postProcessHtml'>,
104
169
  metadata?: ReleaseMetadata,
105
170
  ): (request: Request) => Promise<Response> {
106
171
  const stripParams = options?.stripQueryParams ?? DEFAULT_STRIP_PARAMS;
107
172
  const injectHydrate = options?.injectHydrateFlag !== false;
173
+ const deharvest = options?.deharvestLoaderData !== false;
108
174
  const postProcess = options?.postProcessHtml;
109
175
 
110
176
  return async (request: Request): Promise<Response> => {
@@ -149,6 +215,12 @@ export function wrapSsrHandler(
149
215
  html = html.replace('</head>', `${HYDRATE_SCRIPT}</head>`);
150
216
  }
151
217
 
218
+ // Opaque the loader-data blob so crawlers can't harvest its URL-shaped
219
+ // route keys (index routes leak `/{path}/index`). No-op if absent.
220
+ if (deharvest) {
221
+ html = deharvestLoaderData(html);
222
+ }
223
+
152
224
  // 3. App-specific post-processing
153
225
  if (postProcess) {
154
226
  const result = await postProcess(html, request);
package/src/infra.ts DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * @everystack/server/infra — deploy-time helpers for a consumer's `sst.config.ts`.
3
- *
4
- * `pgDumpLayer()` attaches the PostgreSQL client-tools Lambda layer that `db:backup`/`db:restore`
5
- * (and the snapshot-first apply ceremony) need. It replaces the old `PGDUMP_LAYER_ARN` env-var line
6
- * with a zero-config, declarative resource: the framework ships the binaries (@everystack/pg-tools),
7
- * and this publishes them as a content-addressed LayerVersion into the CONSUMER's own account.
8
- *
9
- * Declarative, not imperative: it returns a Pulumi-managed resource's arn (or an escape-hatch
10
- * string), so the SST/Pulumi engine owns the layer's lifecycle — no SDK side effects at config-eval,
11
- * no untracked state, clean teardown. Same artifact sha => same layer name => redeploys are no-ops.
12
- */
13
-
14
- import { manifest, layerZipPath } from '@everystack/pg-tools';
15
-
16
- /**
17
- * SST's ambient config globals, injected so this package typechecks standalone (it must not depend on
18
- * SST's per-app generated types). In `sst.config.ts` you already have both — pass `{ aws, $util }`.
19
- * Typed loosely on purpose; the shapes are Pulumi's and not worth vendoring here.
20
- */
21
- export interface PgDumpLayerContext {
22
- /** SST's global `aws` (@pulumi/aws) — for `aws.lambda.LayerVersion`. */
23
- aws: any;
24
- /** SST's global `$util` (@pulumi/pulumi) — for `$util.asset.FileArchive`. */
25
- $util: any;
26
- }
27
-
28
- export interface PgDumpLayerOptions {
29
- /**
30
- * The stage's PostgreSQL server major. The shipped client must be >= this (pg_dump/pg_restore are
31
- * backward compatible; a client older than the server is refused). Read it from the engine you declare.
32
- */
33
- pgMajor: number;
34
- /**
35
- * Escape hatch: attach this exact layer ARN instead of the managed publish. For consumers who build
36
- * their own (e.g. x86_64, an exotic major) via tools/pgdump-layer. Outranks everything else.
37
- */
38
- layerArn?: string;
39
- }
40
-
41
- let warnedEnv = false;
42
-
43
- /**
44
- * Resolve and attach the pg_dump layer. Returns the ARN to spread into a Function's `layers: []`.
45
- *
46
- * Precedence: explicit `layerArn` > `PGDUMP_LAYER_ARN` (deprecated alias, warns once) > the managed
47
- * content-addressed LayerVersion published into this account.
48
- *
49
- * @example
50
- * import { pgDumpLayer } from '@everystack/server/infra';
51
- * const ops = new sst.aws.Function('Ops', {
52
- * layers: [pgDumpLayer({ aws, $util }, 'PgDump', { pgMajor: 16 })],
53
- * // ...
54
- * });
55
- */
56
- export function pgDumpLayer(
57
- ctx: PgDumpLayerContext,
58
- name: string,
59
- opts: PgDumpLayerOptions,
60
- ): any /* Input<string> — the layer arn; `any` at the Pulumi interop boundary so it spreads into layers:[] */ {
61
- // 1) explicit escape hatch wins.
62
- if (opts.layerArn) return opts.layerArn;
63
-
64
- // 2) deprecated env alias — still honored for one minor line, but warned.
65
- const envArn = process.env.PGDUMP_LAYER_ARN;
66
- if (envArn) {
67
- if (!warnedEnv) {
68
- warnedEnv = true;
69
- console.warn(
70
- '[everystack] PGDUMP_LAYER_ARN is deprecated and will be removed in the next minor. ' +
71
- 'Install @everystack/pg-tools and drop the env var (the layer is published automatically), ' +
72
- 'or pass pgDumpLayer(ctx, name, { layerArn }) for a bring-your-own layer.',
73
- );
74
- }
75
- return envArn;
76
- }
77
-
78
- // 3) the managed publish: a content-addressed LayerVersion in the consumer's own account.
79
- if (manifest.major < opts.pgMajor) {
80
- throw new Error(
81
- `@everystack/pg-tools ships pg${manifest.major} client tools, but this stage's server is ` +
82
- `pg${opts.pgMajor}. pg_dump/pg_restore must be >= the server. Upgrade @everystack/pg-tools ` +
83
- `to advance the client ceiling, or pass a bring-your-own layerArn.`,
84
- );
85
- }
86
- const sha8 = manifest.sha256.slice(0, 8);
87
- const layer = new ctx.aws.lambda.LayerVersion(name, {
88
- // content-addressed: same artifact => same name => Pulumi treats redeploys as no-ops.
89
- layerName: `everystack-pgdump${manifest.major}-${sha8}`,
90
- code: new ctx.$util.asset.FileArchive(layerZipPath()),
91
- compatibleRuntimes: ['nodejs20.x'],
92
- compatibleArchitectures: [manifest.arch],
93
- description: `everystack pg_dump/pg_restore — pg${manifest.postgresql}, sha ${sha8}`,
94
- });
95
- return layer.arn;
96
- }