@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 +3 -3
- package/src/backup-run.ts +80 -6
- package/src/backup.ts +34 -3
- package/src/db.ts +35 -8
- package/src/image.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
167
|
-
"@everystack/cli": "0.4.
|
|
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()
|
|
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(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
276
|
+
* Operator database connection — THE OPS CHANNEL ONLY.
|
|
277
277
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
-
*
|
|
320
|
-
*
|
|
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
|
*
|