@everystack/server 0.4.19 → 0.4.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
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>",
@@ -163,8 +163,8 @@
163
163
  "sst": "4.13.1",
164
164
  "ts-jest": "29.4.9",
165
165
  "typescript": "5.9.3",
166
- "@everystack/auth": "0.4.2",
167
- "@everystack/cli": "0.4.49"
166
+ "@everystack/auth": "0.4.3",
167
+ "@everystack/cli": "0.4.60"
168
168
  },
169
169
  "scripts": {
170
170
  "test": "jest",
package/src/backup-run.ts CHANGED
@@ -99,6 +99,42 @@ export interface BackupTarget {
99
99
  * on a successful upload alone. The id+key are minted by the caller (the dispatcher) so the CLI
100
100
  * knows the artifact at dispatch time; this IO never stamps its own clock into the id.
101
101
  */
102
+ /**
103
+ * Build a backup's `.meta.json` — the manifest the destructive-apply gate reads.
104
+ *
105
+ * Extracted as a pure choke point so the `metaVersion` stamp is a testable property rather than a
106
+ * line buried inside a function that spawns pg_dump and talks to S3. Deleting the stamp inside
107
+ * `runBackup` left all 687 server tests green while silently making every future backup unusable
108
+ * to the gate; with one choke point, presence IS the property.
109
+ *
110
+ * `metaVersion: 2` means the fingerprint was computed by the OPERATOR and passed in, not computed
111
+ * here. The gate refuses anything older, because a task-computed fingerprint came from a copy of
112
+ * the CLI frozen into a digest-pinned image. It is stamped ONLY when a fingerprint actually rode in
113
+ * — a manifest with no fingerprint must not claim v2 provenance for a value it does not have.
114
+ */
115
+ export function buildBackupMeta(input: {
116
+ id: string;
117
+ stage: string;
118
+ key: string;
119
+ createdAt: string;
120
+ bytes?: number;
121
+ fingerprint?: string;
122
+ /** Who computed it. Defaults to 'operator' — the only producer today. */
123
+ fingerprintSource?: string;
124
+ // Returns the CONSUMER's type, not Record<string, unknown>: that is what makes producer/consumer
125
+ // drift a compile error instead of a runtime surprise the gate discovers.
126
+ }): BackupManifest {
127
+ const { id, stage, key, createdAt, bytes, fingerprint, fingerprintSource } = input;
128
+ return {
129
+ id, stage, createdAt, bytes, key,
130
+ // `fingerprintSource` records HOW the value was obtained, not just that it exists. The gate
131
+ // accepts an operator-computed fingerprint by design, so that decision should be auditable
132
+ // after the fact rather than reconstructed from version archaeology — and a future gate can
133
+ // tighten on it without guessing.
134
+ ...(fingerprint ? { fingerprint, metaVersion: 2, fingerprintSource: fingerprintSource ?? 'operator' } : {}),
135
+ };
136
+ }
137
+
102
138
  export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Promise<BackupSummary> {
103
139
  const { bucket, region, adminUrl } = deps;
104
140
  const { stage, id, key, fingerprint } = target;
@@ -153,7 +189,7 @@ export async function runBackup(deps: BackupRunDeps, target: BackupTarget): Prom
153
189
  bytes = head.ContentLength;
154
190
  } catch { /* size is best-effort */ }
155
191
 
156
- const meta = { id, stage, createdAt: new Date().toISOString(), bytes, key, ...(fingerprint ? { fingerprint } : {}) };
192
+ const meta = buildBackupMeta({ id, stage, key, bytes, fingerprint, createdAt: new Date().toISOString() });
157
193
  await s3.send(new PutObjectCommand({
158
194
  Bucket: bucket, Key: mkey, Body: JSON.stringify(meta),
159
195
  ContentType: 'application/json', ServerSideEncryption: 'AES256',
@@ -262,11 +298,49 @@ export async function latestBackupManifest(deps: BackupRunDeps, stage: string):
262
298
  * PROVEN stable across the dump window (a concurrent DDL), so the safety gate never trusts a manifest
263
299
  * whose fp might not match the schema the dump actually holds.
264
300
  */
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(() => {});
301
+ export async function deleteBackup(
302
+ deps: Pick<BackupRunDeps, 'bucket' | 'region'> & {
303
+ /** Injected S3 client — the seam that lets the retraction's failure paths be tested. */
304
+ s3?: { send: (command: unknown) => Promise<unknown> };
305
+ },
306
+ key: string,
307
+ ): Promise<void> {
308
+ const s3 = deps.s3 ?? (await getS3(deps.region));
309
+ const { DeleteObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
310
+
311
+ // S3 DELETE is idempotent and answers 204 for a key that was never there, so a successful call is
312
+ // not proof the object is gone. Confirm with a HEAD — and discriminate the error: only a NOT-FOUND
313
+ // means gone. Treating every failure as success (throttling, AccessDenied, an expired credential,
314
+ // a socket timeout) makes the confirmation vacuous exactly when it matters.
315
+ const gone = async (k: string): Promise<boolean> => {
316
+ try {
317
+ await s3.send(new HeadObjectCommand({ Bucket: deps.bucket, Key: k }));
318
+ return false;
319
+ } catch (err: any) {
320
+ const status = err?.$metadata?.httpStatusCode;
321
+ const name = err?.name ?? err?.Code;
322
+ if (status === 404 || name === 'NotFound' || name === 'NoSuchKey') return true;
323
+ throw new Error(`could not confirm ${k} was deleted (${name ?? 'unknown error'}): ${err?.message ?? String(err)}`);
324
+ }
325
+ };
326
+
327
+ // MANIFEST FIRST, and confirm it before touching the dump. The two orientations are not
328
+ // symmetric: a surviving manifest whose dump is gone reads as a valid backup to
329
+ // `fetchBackupManifest`, and nothing downstream checks the dump exists — so a destructive apply
330
+ // would proceed over a "snapshot" that cannot be restored. A surviving dump whose manifest is
331
+ // gone simply fails to resolve, which refuses. Delete toward the safe orientation.
332
+ //
333
+ // Do NOT swallow either call. This used to `.catch(() => {})` both, so a retraction that failed
334
+ // was reported as a successful one while the unproven artifact stayed in the bucket.
335
+ const mkey = metaKey(key);
336
+ await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: mkey }));
337
+ if (!(await gone(mkey))) {
338
+ throw new Error(`the manifest ${mkey} still exists after DELETE — it may be under an object lock or a retention policy. Remove it by hand; the safety gate must never resolve it.`);
339
+ }
340
+ await s3.send(new DeleteObjectCommand({ Bucket: deps.bucket, Key: key }));
341
+ if (!(await gone(key))) {
342
+ throw new Error(`the manifest is gone but the dump ${key} still exists after DELETE — the backup can no longer be resolved by the gate, but remove the dump by hand to reclaim the storage.`);
343
+ }
270
344
  }
271
345
 
272
346
  /** List a stage's backups (newest first) from S3 object metadata — one list call, no per-item gets. */
package/src/backup.ts CHANGED
@@ -146,6 +146,23 @@ export interface BackupManifest {
146
146
  createdAt: string;
147
147
  /** The base-schema fingerprint captured at dump time (absent on pre-B3 backups). */
148
148
  fingerprint?: string;
149
+ /**
150
+ * Who computed `fingerprint`.
151
+ *
152
+ * Absent or 1: the TASK IMAGE computed it, from a copy of the CLI frozen into a digest-pinned
153
+ * image at publish time. That copy could disagree with the CLI now doing the comparing, so such a
154
+ * fingerprint cannot be trusted as the safety gate's tie.
155
+ *
156
+ * 2: the OPERATOR computed it, with the same code `db:apply` uses, and the CLI verified the task
157
+ * stamped exactly what it sent.
158
+ */
159
+ metaVersion?: number;
160
+ /**
161
+ * HOW the fingerprint was obtained ('operator' today). Recorded so accepting an
162
+ * operator-computed value is an auditable decision rather than version archaeology, and so a
163
+ * future gate can tighten on provenance without a cast.
164
+ */
165
+ fingerprintSource?: string;
149
166
  bytes?: number;
150
167
  key: string;
151
168
  }
@@ -182,14 +199,28 @@ export function verifySnapshotRef(input: SnapshotVerifyInput): SnapshotVerdict {
182
199
  if (!manifest.fingerprint) {
183
200
  return { ok: false, reason: `snapshot ${manifest.id} predates fingerprint stamping — ${takeFresh}` };
184
201
  }
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
- }
202
+ // Freshness first among the remaining checks: it is independent of everything below and is by far
203
+ // the most common reason a snapshot is rejected, so it should be what the operator reads.
188
204
  const ageMs = now.getTime() - Date.parse(manifest.createdAt);
189
205
  if (!(ageMs >= 0) || ageMs > freshnessWindowMs) {
190
206
  const mins = Math.round(ageMs / 60_000);
191
207
  return { ok: false, reason: `snapshot ${manifest.id} is ${mins} min old (freshness window ${Math.round(freshnessWindowMs / 60_000)} min) — ${takeFresh}` };
192
208
  }
209
+ // Provenance BEFORE the tie: a pre-v2 manifest's fingerprint was computed INSIDE the task image,
210
+ // by a copy of the CLI frozen at publish time, so comparing it against this plan's fingerprint
211
+ // compares two things that were not necessarily produced the same way. Checking the tie first
212
+ // would report a mismatch as if the schema differed, sending the operator after the wrong thing.
213
+ // With an hour-long freshness window this only ever refuses a backup taken in the last hour by a
214
+ // pre-upgrade CLI — and the remedy is the backup they were going to take anyway.
215
+ if (!(manifest.metaVersion && manifest.metaVersion >= 2)) {
216
+ return {
217
+ ok: false,
218
+ reason: `snapshot ${manifest.id} carries a fingerprint computed inside the task image, which can disagree with this CLI — ${takeFresh}`,
219
+ };
220
+ }
221
+ if (manifest.fingerprint !== planFrom) {
222
+ 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}` };
223
+ }
193
224
  return { ok: true, id: manifest.id };
194
225
  }
195
226
 
package/src/db.ts CHANGED
@@ -273,12 +273,34 @@ let adminSqlClient: ReturnType<typeof postgres> | null = null;
273
273
  let adminFallbackWarned = false;
274
274
 
275
275
  /**
276
- * Operator database connection for privileged tasks (migrate, seed, console).
276
+ * Operator database connection — THE OPS CHANNEL ONLY.
277
277
  *
278
- * Connects with ADMIN_DATABASE_URL when configured. When it isn't, falls back
279
- * to the regular DATABASE_URL connection with a one-time warning — existing
280
- * single-credential apps keep working, but RLS is only fail-closed once the
281
- * credentials are split. See docs/plans/admin-database-url.md.
278
+ * migrate · seed · console · backup. Nothing else.
279
+ *
280
+ * **Never call this from request-serving code.** This credential owns the schema, so RLS
281
+ * does not apply to it. A request handler holding it has unrestricted database access and
282
+ * every RLS policy you wrote is decoration. It will work, which is exactly the problem —
283
+ * the failure is silent and total.
284
+ *
285
+ * Trusted app writes (issuing a credential at signup, recording a webhook, a background
286
+ * reconcile) do NOT belong here. The lawful pattern is a NOLOGIN service role granted to
287
+ * `authenticator`, entered via `withRole` after the handler's own authorization check:
288
+ *
289
+ * if (!(await mayIssueCredential(user))) throw new HttpError(403);
290
+ * await withRole(sql, 'service', claims, (tx) => tx`INSERT INTO credentials ...`);
291
+ *
292
+ * That keeps the trusted lane on the app plane, scoped to the privileges the lane actually
293
+ * needs. See the "Two Credential Channels" section of everystack://security.
294
+ *
295
+ * Connects with ADMIN_DATABASE_URL when configured. When it isn't, falls back to the
296
+ * Postgres master, then to the API connection. **That fallback is a migration aid, not a
297
+ * supported posture** — RLS is only fail-closed once the credentials are split.
298
+ * `everystack db:provision --stage <name>` splits a DEPLOYED stage; it requires `--stage`
299
+ * because the new credentials are never displayed and must land in the secret store. Local
300
+ * single-credential development has no equivalent yet, so a local run legitimately takes this
301
+ * fallback — which is why the guard that refuses a privileged app credential must not treat
302
+ * local dev as a violation. See
303
+ * plan:the-operator-credential-is-the-ops-channel-only-doctrine-doc.
282
304
  */
283
305
  export function createAdminDb<T extends Record<string, unknown>>(
284
306
  schema: T,
@@ -315,9 +337,14 @@ export function createAdminDb<T extends Record<string, unknown>>(
315
337
  }
316
338
 
317
339
  /**
318
- * Raw postgres.js client for the operator connection.
319
- * Must be called after createAdminDb(). Falls back to the API client when
320
- * the credentials aren't split (same fallback as createAdminDb).
340
+ * Raw postgres.js client for the operator connection — THE OPS CHANNEL ONLY.
341
+ *
342
+ * Same rule as `createAdminDb`: never reachable from a request handler. If you are holding
343
+ * this in code that serves a request, you have turned RLS off for that path. Use a NOLOGIN
344
+ * service role via `withRole` instead — see `createAdminDb` above.
345
+ *
346
+ * Must be called after createAdminDb(). Falls back to the API client when the credentials
347
+ * aren't split (same fallback, same caveat: a migration aid, not a supported posture).
321
348
  */
322
349
  export function getAdminSql(): ReturnType<typeof postgres> {
323
350
  if (adminSqlClient) return adminSqlClient;
package/src/image.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /// <reference path="./heic-decode.d.ts" />
2
+ // `heic-decode` ships no types, so this file needs the ambient shim NEXT TO IT — not merely
3
+ // present in the package. ts-jest compiles per file, and an ambient .d.ts nothing imports
4
+ // enters the program only via `include`/`files`, which this package's own tsconfig supplies
5
+ // and a CONSUMER's does not. So the shim covered `packages/server`'s suite and no one else:
6
+ // the example app type-checked this same source and failed on TS7016. The reference directive
7
+ // binds the declaration to the source that needs it, so every program compiling image.ts gets
8
+ // it. (Inert at runtime — erased with the types.)
9
+
1
10
  /**
2
11
  * @everystack/server/image — On-demand image processing Lambda.
3
12
  *