cursedbelt-server 4.18.0 → 4.19.0
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/dist/server/activity/index.d.ts +2 -1
- package/dist/server/activity/index.js +2 -1
- package/dist/server/auth/passwordCost.d.ts +21 -0
- package/dist/server/auth/passwordCost.js +80 -0
- package/dist/server/bench/index.d.ts +1 -0
- package/dist/server/bench/index.js +1 -0
- package/dist/server/bench/tail.d.ts +110 -0
- package/dist/server/bench/tail.js +182 -0
- package/dist/server/d1/index.d.ts +1 -2
- package/dist/server/d1/index.js +9 -9
- package/dist/server/d1/pullD1.js +16 -3
- package/dist/server/engagement/api.d.ts +71 -0
- package/dist/server/engagement/api.js +84 -0
- package/dist/server/engagement/env.d.ts +18 -0
- package/dist/server/engagement/env.js +52 -0
- package/dist/server/engagement/index.d.ts +55 -0
- package/dist/server/engagement/index.js +55 -0
- package/dist/server/engagement/places.d.ts +22 -0
- package/dist/server/engagement/places.js +63 -0
- package/dist/server/engagement/policy.d.ts +168 -0
- package/dist/server/engagement/policy.js +202 -0
- package/dist/server/engagement/store.d.ts +92 -0
- package/dist/server/engagement/store.js +223 -0
- package/dist/server/engagement/summary.d.ts +102 -0
- package/dist/server/engagement/summary.js +127 -0
- package/dist/server/engagement/types.d.ts +42 -0
- package/dist/server/engagement/types.js +12 -0
- package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
- package/dist/server/maps-budget/mapsBudget.js +193 -0
- package/dist/server/satellite/config.d.ts +173 -0
- package/dist/server/satellite/config.js +259 -0
- package/dist/server/satellite/door.d.ts +112 -0
- package/dist/server/satellite/door.js +149 -0
- package/dist/server/storage/binaryStore.d.ts +18 -0
- package/dist/server/storage/binaryStore.js +32 -1
- package/dist/server/storage/derivatives.d.ts +253 -0
- package/dist/server/storage/derivatives.js +266 -0
- package/dist/server/storage/uploadSession.d.ts +75 -0
- package/dist/server/storage/uploadSession.js +74 -0
- package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
- package/docs/activity.md +43 -0
- package/docs/engagement.md +47 -0
- package/docs/notifications.md +43 -0
- package/docs/retention.md +81 -0
- package/docs/skipped-tests.md +19 -0
- package/package.json +46 -9
- package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
- package/src/leafSubpathsImportNothing.spec.ts +49 -0
- package/src/server/activity/index.ts +2 -1
- package/src/server/auth/passwordCost.spec.ts +42 -0
- package/src/server/auth/passwordCost.ts +87 -0
- package/src/server/bench/index.ts +13 -0
- package/src/server/bench/tail.spec.ts +126 -0
- package/src/server/bench/tail.ts +237 -0
- package/src/server/d1/index.ts +9 -9
- package/src/server/d1/pullD1.spec.ts +20 -0
- package/src/server/d1/pullD1.ts +18 -2
- package/src/server/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- package/src/server/d1/kysely.ts +0 -169
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/satellite-door` — the PURE half of the satellite configuration contract:
|
|
3
|
+
* who an instance is (a release, a stage, a test runner, a production build) and who it opens
|
|
4
|
+
* for. No `node:*` import, no `process` read unless one exists, no package — so a Cloudflare
|
|
5
|
+
* Worker's door imports the SAME rules its Mac twin does.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this is here, since 4.19.0 (task 281)
|
|
8
|
+
*
|
|
9
|
+
* Nine apps held an `appConfig.ts` in five versions (measured 2026-09-23: `roms`, `family`,
|
|
10
|
+
* `music`, `collections` and `auth` on one 445-line lineage; `vault` a trimmed fork of it;
|
|
11
|
+
* `flix`, `station` and `patterns` a second lineage). The divergence was not taste — it was
|
|
12
|
+
* protection lost in one copy and kept in another: `vault`, `flix` and `station` lost the stage
|
|
13
|
+
* identity override, so a stage of any of them would have signed in against PRODUCTION auth;
|
|
14
|
+
* `station` admitted its extra e-mails on its deployed release; lineage B could not tell an
|
|
15
|
+
* `APP_RELEASE=0` scratch boot from a release. The rules below are the union, decided once.
|
|
16
|
+
*
|
|
17
|
+
* And the Workers needed them without the Node half: `music`'s Worker imported the whole
|
|
18
|
+
* `appConfig.ts` to reach two pure functions, dragging `node:fs` and the data-dir chain into
|
|
19
|
+
* its bundle, while `collections` split a `doorConstants.ts` out by hand to avoid the same.
|
|
20
|
+
* This leaf is that split, once. `./satellite-config` is the Bun/Node half and re-exports all
|
|
21
|
+
* of this, so a Mac-side caller needs one import.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **This file imports NOTHING.** `src/leafSubpathsImportNothing.spec.ts` proves it in a
|
|
24
|
+
* fixture with no packages resolvable; an import that survives transpilation turns that red.
|
|
25
|
+
*
|
|
26
|
+
* 🔴 It knows the fleet's ZONE (`cursedalchemy.com`) and its accounts app's ports, as the
|
|
27
|
+
* binary-store client already does — mechanism of this fleet, not of any app. It does NOT
|
|
28
|
+
* know the owner: `ownerEmail` is always passed in.
|
|
29
|
+
*/
|
|
30
|
+
/** The accounts app (`apps/auth`) in production. */
|
|
31
|
+
export const PROD_AUTH_URL = "https://auth.cursedalchemy.com";
|
|
32
|
+
/** The accounts app's dev shell. */
|
|
33
|
+
export const DEV_AUTH_URL = "http://localhost:3309";
|
|
34
|
+
/**
|
|
35
|
+
* The LOCAL accounts door a stage signs in against — loopback, never the public hostname, so a
|
|
36
|
+
* stage's test sign-ins never write a row into the owner's real accounts registry.
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_AUTH_URL = "http://127.0.0.1:3309";
|
|
39
|
+
/** `APP_STAGE_IDENTITY=local` — the marker a stage boots with. */
|
|
40
|
+
export const STAGE_IDENTITY_ENV = "APP_STAGE_IDENTITY";
|
|
41
|
+
/** The one directory under the state root that is fixtures by construction. */
|
|
42
|
+
export const TEST_SCRATCH_DIR_NAME = "test-scratch.noindex";
|
|
43
|
+
/**
|
|
44
|
+
* Every marker that means "a test runner is in charge". A spec that spawns a REAL server strips
|
|
45
|
+
* exactly these ({@link withoutTestRuntimeMarkers}), so a fourth marker added here is stripped by
|
|
46
|
+
* everyone for free.
|
|
47
|
+
*/
|
|
48
|
+
export const TEST_RUNTIME_ENV_KEYS = ["NODE_ENV", "BUN_TEST", "SATELLITE_TEST_RUNTIME"];
|
|
49
|
+
/** `process.env` where there is one; `{}` in a Worker, which has none. */
|
|
50
|
+
const ambientEnv = () => globalThis.process?.env ?? {};
|
|
51
|
+
/** Is this instance a stage whose identity must stay LOCAL? */
|
|
52
|
+
export function stageIdentityIsLocal(env = ambientEnv()) {
|
|
53
|
+
return env[STAGE_IDENTITY_ENV]?.trim().toLowerCase() === "local";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Is this origin an ephemeral STAGE — `<app>-stage.cursedalchemy.com`?
|
|
57
|
+
*
|
|
58
|
+
* Derived from the hostname, never configured: a `-stage` name in the fleet's own zone cannot be
|
|
59
|
+
* typed into existence (only `stage up` creates the record, behind one Access application), so a
|
|
60
|
+
* production release cannot acquire one by a typo in a secrets file.
|
|
61
|
+
*/
|
|
62
|
+
export function isStageOrigin(publicUrl) {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(publicUrl).hostname.endsWith("-stage.cursedalchemy.com");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Is a test runner in charge? The three markers, and `SATELLITE_TEST_RUNTIME` because the React
|
|
72
|
+
* preloads force `NODE_ENV=development` and erase Bun's own `test`.
|
|
73
|
+
*/
|
|
74
|
+
export function isTestRuntime(env = ambientEnv()) {
|
|
75
|
+
return env.NODE_ENV === "test" || Boolean(env.BUN_TEST) || env.SATELLITE_TEST_RUNTIME === "1";
|
|
76
|
+
}
|
|
77
|
+
/** A copy of `env` with every test-runtime marker removed — what a spec hands a REAL server it spawns. */
|
|
78
|
+
export function withoutTestRuntimeMarkers(env) {
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const [key, value] of Object.entries(env)) {
|
|
81
|
+
if (value === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
if (TEST_RUNTIME_ENV_KEYS.includes(key))
|
|
84
|
+
continue;
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* IS THIS A RELEASE? One question, one variable.
|
|
91
|
+
*
|
|
92
|
+
* `APP_RELEASE` is stamped by the deploy and by nothing else; `0`/`false` is an explicit no (a
|
|
93
|
+
* stage plan and a floor test both rely on that — lineage B ignored it and could not). Without
|
|
94
|
+
* the stamp it falls back to `APP_DATA_DIR`, minus the test-scratch tree, because a release
|
|
95
|
+
* shipped before the stamp existed must still answer yes: flipping it to no would open the smoke
|
|
96
|
+
* extras on the open internet. The fallback retires itself as each app redeploys.
|
|
97
|
+
*/
|
|
98
|
+
export function isRelease(env = ambientEnv()) {
|
|
99
|
+
const explicit = env.APP_RELEASE?.trim();
|
|
100
|
+
if (explicit)
|
|
101
|
+
return explicit !== "0" && explicit.toLowerCase() !== "false";
|
|
102
|
+
const appDataDir = env.APP_DATA_DIR?.trim() ?? "";
|
|
103
|
+
return Boolean(appDataDir) && !appDataDir.includes(TEST_SCRATCH_DIR_NAME);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Is this a DEV runtime? Vite's `import.meta.env.DEV` when a bundle carries it, else
|
|
107
|
+
* `NODE_ENV !== "production"` of the env given (or the ambient one).
|
|
108
|
+
*/
|
|
109
|
+
export function isDevRuntime(env) {
|
|
110
|
+
if (env === undefined) {
|
|
111
|
+
const viteDev = import.meta.env?.DEV;
|
|
112
|
+
if (typeof viteDev === "boolean")
|
|
113
|
+
return viteDev;
|
|
114
|
+
}
|
|
115
|
+
return (env ?? ambientEnv()).NODE_ENV !== "production";
|
|
116
|
+
}
|
|
117
|
+
/** The inverse of {@link isDevRuntime} — what `assertSatelliteUsable`'s refusal keys on with `deployed`. */
|
|
118
|
+
export function isProductionRuntime(env) {
|
|
119
|
+
return !isDevRuntime(env);
|
|
120
|
+
}
|
|
121
|
+
/** `a@x, ,b@x` → `["a@x", "b@x"]`. The one parse for every `*_EXTRA_ALLOWED_EMAILS`. */
|
|
122
|
+
export function parseEmailList(raw) {
|
|
123
|
+
return (raw ?? "")
|
|
124
|
+
.split(",")
|
|
125
|
+
.map((email) => email.trim())
|
|
126
|
+
.filter((email) => email.length > 0);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* May extra accounts widen the door on THIS instance? Only off a release, or on a stage.
|
|
130
|
+
*
|
|
131
|
+
* 🔴 Keyed on `deployed`, not on the origin and not on loopback: a gate's artifact smoke runs the
|
|
132
|
+
* PRODUCTION build on `127.0.0.1`, so `live` is true there and the origin falls back to the prod
|
|
133
|
+
* hostname — keying on either would leave the extras inert in the one place they exist to work.
|
|
134
|
+
* Derived rather than configured: a boolean meaning "let someone else in" is one typo from true
|
|
135
|
+
* in production.
|
|
136
|
+
*/
|
|
137
|
+
export function extraEmailsApply(config) {
|
|
138
|
+
if (!config.deployed)
|
|
139
|
+
return true;
|
|
140
|
+
return isStageOrigin(config.publicUrl ?? "");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The addresses this instance opens for, owner first — what an owner-only app hands its SSO
|
|
144
|
+
* consumer's `allowedEmails`. One function, so the server, `/healthz` and the tests cannot
|
|
145
|
+
* disagree about the answer.
|
|
146
|
+
*/
|
|
147
|
+
export function allowedEmailsFor(config) {
|
|
148
|
+
return extraEmailsApply(config) ? [config.ownerEmail, ...config.extraAllowedEmails] : [config.ownerEmail];
|
|
149
|
+
}
|
|
@@ -322,6 +322,24 @@ export declare const DELETE_TOKEN_TTL_SECONDS = 120;
|
|
|
322
322
|
/** register-app prints the key as base64(PKCS8 PEM); env files sometimes carry
|
|
323
323
|
* raw PEM or PEM with literal `\n` sequences. Accept all three. */
|
|
324
324
|
export declare function normalizePrivateKeyPem(raw: string): string;
|
|
325
|
+
/**
|
|
326
|
+
* A short, stable, PUBLIC identifier for a tenant's signing key — the first 16 hex of sha256
|
|
327
|
+
* over the public half's SPKI DER. `""` for an unset key, `"unreadable"` for one that does not
|
|
328
|
+
* parse; never throws.
|
|
329
|
+
*
|
|
330
|
+
* 🔴 Why it exists. A wrong or stale `FILE_TOKEN_PRIVATE_KEY` is the failure that hides best:
|
|
331
|
+
* every list renders, `/healthz` is 200, and only the file bytes 403 — and the assertion that
|
|
332
|
+
* would catch it ("a file URL serves bytes") needs a signed-in session a deploy smoke does not
|
|
333
|
+
* have. So an instance publishes this digest on `/healthz` and the smoke derives the same digest
|
|
334
|
+
* from the key the deploying machine holds; the two are compared with no session anywhere.
|
|
335
|
+
*
|
|
336
|
+
* Safe to publish: a truncated hash of the PUBLIC half, which binary-server's tenant registry
|
|
337
|
+
* already holds. It was `fileTokenKeyId` in four apps' `src/server/config.ts` (music first, then
|
|
338
|
+
* family, roms and collections), byte-identical, until 4.19.0 (task 321). Lives here because it
|
|
339
|
+
* is `node:crypto` over the same key {@link createBinaryStore} signs with — and so it runs in a
|
|
340
|
+
* Worker under `nodejs_compat` exactly as the store does.
|
|
341
|
+
*/
|
|
342
|
+
export declare function fileTokenKeyId(privateKeyPem: string): string;
|
|
325
343
|
export declare function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore;
|
|
326
344
|
/**
|
|
327
345
|
* Which binary-server TENANT this process is — the app's own key, unless the environment
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* import specifier (`cursedbelt/server/storage`, the pre-split package name) — no
|
|
41
41
|
* behavioural difference in any of the three, so nothing was dropped to land the union.
|
|
42
42
|
*/
|
|
43
|
-
import { createPrivateKey, sign as cryptoSign } from 'node:crypto';
|
|
43
|
+
import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign } from 'node:crypto';
|
|
44
44
|
/*
|
|
45
45
|
* ── The process-wide repeat register (§ the repeat-key counter) ───────────────
|
|
46
46
|
*
|
|
@@ -196,6 +196,37 @@ export function normalizePrivateKeyPem(raw) {
|
|
|
196
196
|
}
|
|
197
197
|
return decoded;
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* A short, stable, PUBLIC identifier for a tenant's signing key — the first 16 hex of sha256
|
|
201
|
+
* over the public half's SPKI DER. `""` for an unset key, `"unreadable"` for one that does not
|
|
202
|
+
* parse; never throws.
|
|
203
|
+
*
|
|
204
|
+
* 🔴 Why it exists. A wrong or stale `FILE_TOKEN_PRIVATE_KEY` is the failure that hides best:
|
|
205
|
+
* every list renders, `/healthz` is 200, and only the file bytes 403 — and the assertion that
|
|
206
|
+
* would catch it ("a file URL serves bytes") needs a signed-in session a deploy smoke does not
|
|
207
|
+
* have. So an instance publishes this digest on `/healthz` and the smoke derives the same digest
|
|
208
|
+
* from the key the deploying machine holds; the two are compared with no session anywhere.
|
|
209
|
+
*
|
|
210
|
+
* Safe to publish: a truncated hash of the PUBLIC half, which binary-server's tenant registry
|
|
211
|
+
* already holds. It was `fileTokenKeyId` in four apps' `src/server/config.ts` (music first, then
|
|
212
|
+
* family, roms and collections), byte-identical, until 4.19.0 (task 321). Lives here because it
|
|
213
|
+
* is `node:crypto` over the same key {@link createBinaryStore} signs with — and so it runs in a
|
|
214
|
+
* Worker under `nodejs_compat` exactly as the store does.
|
|
215
|
+
*/
|
|
216
|
+
export function fileTokenKeyId(privateKeyPem) {
|
|
217
|
+
if (!privateKeyPem.trim())
|
|
218
|
+
return '';
|
|
219
|
+
try {
|
|
220
|
+
const spki = createPublicKey(createPrivateKey(normalizePrivateKeyPem(privateKeyPem))).export({
|
|
221
|
+
type: 'spki',
|
|
222
|
+
format: 'der',
|
|
223
|
+
});
|
|
224
|
+
return createHash('sha256').update(spki).digest('hex').slice(0, 16);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return 'unreadable';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
199
230
|
const base64url = (bytes) => Buffer.from(bytes).toString('base64url');
|
|
200
231
|
/** Keys may contain `/` as a real separator; each SEGMENT is percent-encoded
|
|
201
232
|
* because the server decodes the whole path before matching it to the token. */
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── `cursedbelt-server/storage/derivatives` — how an app READS what binary-server derives ──
|
|
3
|
+
*
|
|
4
|
+
* Since 4.19.0 (task 344). The key NAMES already lived here (`./videoDerivativeKeys.ts`, on the
|
|
5
|
+
* owner's 2026-08-14 ruling — *"Make it in cb because many apps may move to cloudflare and share
|
|
6
|
+
* the logic"*); what was still copied into `collections` and `family` as `src/kit/imageDerivatives.ts`
|
|
7
|
+
* and `src/kit/videoDerivatives.ts` was the READING strategy built on them — the part with the
|
|
8
|
+
* judgement in it. The video copy had already forked (three import lines and a doc comment, benign
|
|
9
|
+
* by accident), and the HEIC rule below decides whether a photograph appears AT ALL, which may not
|
|
10
|
+
* exist in two places.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 Store-agnostic throughout: every function takes the caller's own store or signer, so nothing
|
|
13
|
+
* here can reach across tenants — each app is its own binary-server tenant and must stay unable to
|
|
14
|
+
* resolve another's objects.
|
|
15
|
+
*
|
|
16
|
+
* The key names are RE-EXPORTED from here, so a consumer changes one import specifier and not
|
|
17
|
+
* every call site. This file imports only `./videoDerivativeKeys.ts`, which imports nothing, so it
|
|
18
|
+
* mounts unchanged inside a Worker (`src/leafSubpathsImportNothing.spec.ts` proves it).
|
|
19
|
+
*/
|
|
20
|
+
export { hlsMasterKeyFor, hlsPrefixFor, posterKeyFor, spriteKeyFor, spriteVttKeyFor, webPlayableKeyFor, } from "./videoDerivativeKeys.js";
|
|
21
|
+
/**
|
|
22
|
+
* ── 🔴 The STILL-image derivatives binary-server writes, and how an app READS them ───
|
|
23
|
+
*
|
|
24
|
+
* The sibling of `videoDerivatives.ts`, and it exists for a different reason worth being
|
|
25
|
+
* explicit about. A video's remux is about SPEED — the original plays, eventually. A still's
|
|
26
|
+
* `.web.jpg` is, for a large and growing share of the fleet's photographs, about the picture
|
|
27
|
+
* appearing AT ALL.
|
|
28
|
+
*
|
|
29
|
+
* ── The report that produced this file (owner, 2026-08-14) ───────────────────────────
|
|
30
|
+
* *"determine why a full album shows up with no thumbnails and videos and images not
|
|
31
|
+
* working"* — `family.cursedalchemy.com` → Keepsakes, one album of 85 items drawn as a wall
|
|
32
|
+
* of file names with a torn-page glyph on every tile, and the album's own cover blank.
|
|
33
|
+
*
|
|
34
|
+
* Nothing was missing and nothing had failed. Measured that day against real prod: every one
|
|
35
|
+
* of those rows answered `/api/sources/:id/file` with **HTTP 200 and
|
|
36
|
+
* `Content-Type: image/heic`**. `apps/family` served the bytes the family uploaded, exactly
|
|
37
|
+
* as designed — and **no browser except Safari can decode HEIC**, so a correct 200 rendered
|
|
38
|
+
* as a broken image in Chrome and Firefox. Every iPhone in the household shoots HEIC by
|
|
39
|
+
* default; 86 of the tenant's 182 stored stills were HEIC.
|
|
40
|
+
*
|
|
41
|
+
* binary-server had already done its half. It classes `heic`/`heif`/`tiff`/`x-adobe-dng` as
|
|
42
|
+
* UNVIEWABLE and exempts them from its "too small to bother" floor precisely because for
|
|
43
|
+
* those *"the derivative is not a saving — it is the only copy anything can display"*. All
|
|
44
|
+
* 86 had both a `.web.jpg` and a `.thumb.jpg` sitting beside them. The app simply never asked
|
|
45
|
+
* for either.
|
|
46
|
+
*
|
|
47
|
+
* ── 🔴 The two rules a consumer needs ────────────────────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* 1. **A picture on a SCREEN is `webImageKeyFor(key)` when that object exists**, and the
|
|
50
|
+
* original only when it does not. This is not merely an optimization: for an unviewable
|
|
51
|
+
* original it is the difference between a photograph and a broken image.
|
|
52
|
+
* 2. **A picture on a DOWNLOAD is always the original.** A person asking for their own file
|
|
53
|
+
* must get the file they gave us — full resolution, original container, original
|
|
54
|
+
* metadata. A re-encode handed over as "your photograph" is a quiet, permanent loss on
|
|
55
|
+
* the one road where the family expects the archive to be an archive.
|
|
56
|
+
*
|
|
57
|
+
* The tile picture is `posterKeyFor` (below, with the video rules) — `<key>.thumb.jpg`, deliberately
|
|
58
|
+
* the SAME name a video's still gets, so a grid has one rule for everything it draws. It is
|
|
59
|
+
* not duplicated.
|
|
60
|
+
*
|
|
61
|
+
* ── 🔴 Absence is "not ready yet", and it CANNOT be probed per row ───────────────────
|
|
62
|
+
* As everywhere in this contract, a derivative is found by computing its name — so the object
|
|
63
|
+
* being absent IS the not-ready state, with no webhook, no job id and no column. That is
|
|
64
|
+
* cheap for the ONE item a person opened and ruinous for a grid: probing per tile is a
|
|
65
|
+
* network round trip per row, over a Cloudflare Tunnel, from a `MemoryMax=300M` box. So a
|
|
66
|
+
* consumer must never probe to build a list. It offers the derived tile URL for every row and
|
|
67
|
+
* lets the `<img>` fall back to the full picture on error — `cursedbelt/react/media-gallery`'s
|
|
68
|
+
* `ThumbImg` does exactly this, for exactly this reason.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* The web-optimized still: bounded dimensions, re-encoded for size, and for HEIC/TIFF/RAW the
|
|
72
|
+
* only copy a browser can draw.
|
|
73
|
+
*
|
|
74
|
+
* 🔴 `.jpg`, and it is binary-server's key that this must match byte for byte
|
|
75
|
+
* (`src/media/derivativeKeys.ts` → `webImageKeyFor`). The extension is baked into the name and
|
|
76
|
+
* the name is what a consumer computes without asking, so changing the container on either
|
|
77
|
+
* side means an app confidently addressing bytes that are not there — which fails as a 404 on
|
|
78
|
+
* a picture, i.e. silently and only in a browser.
|
|
79
|
+
*/
|
|
80
|
+
export declare const webImageKeyFor: (key: string) => string;
|
|
81
|
+
/**
|
|
82
|
+
* True when NO browser can draw the original, so `webImageKeyFor` is not an optimization but
|
|
83
|
+
* the only copy that can appear on a screen.
|
|
84
|
+
*
|
|
85
|
+
* 🔴 This mirrors binary-server's own `isUnviewableOriginal`
|
|
86
|
+
* (`src/media/imageKinds.ts` → `/^image\/(x-adobe-dng|tiff|heic|heif)$/`), and the two must
|
|
87
|
+
* agree: bs uses it to decide which stills are EXEMPT from its "too small to bother" size
|
|
88
|
+
* floor — *"the derivative is not a saving, it is the only copy anything can display"* — so a
|
|
89
|
+
* consumer that classes a mime differently either offers a derivative bs never built (a
|
|
90
|
+
* broken picture) or serves an original nothing decodes (the same broken picture, with the
|
|
91
|
+
* fix sitting unused in storage). binary-server may import it from here; until it does, the regex is the seam, and it is
|
|
92
|
+
* three words long.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 The NAME is checked too, and that is not belt-and-braces. `File.type` is empty for a
|
|
95
|
+
* `.DNG` in every browser tested and often for an iPhone `.MOV`/`.HEIC`, so an app's catalog
|
|
96
|
+
* frequently holds `application/octet-stream` for exactly the files this predicate exists to
|
|
97
|
+
* catch — the same reason binary-server sniffs the first 4 KB rather than trusting the mime
|
|
98
|
+
* it was handed.
|
|
99
|
+
*/
|
|
100
|
+
export declare const isUnviewableStill: (mime: string | null | undefined, fileName?: string | null) => boolean;
|
|
101
|
+
/**
|
|
102
|
+
* ── 🔴 The video derivatives binary-server writes, and how an app READS them ─────────
|
|
103
|
+
*
|
|
104
|
+
* binary-server remuxes every uploaded video into a browser-playable sibling and samples a
|
|
105
|
+
* scrub-preview sprite sheet + track, naming each result after the source object. **That
|
|
106
|
+
* naming is the whole contract**: a consumer finds a derivative by computing its key and
|
|
107
|
+
* asking `/meta` whether it exists yet, so there is no webhook, no job id, no schema column
|
|
108
|
+
* and nothing to reconcile. The absence of the object is the "not ready yet" state, and it
|
|
109
|
+
* answers itself.
|
|
110
|
+
*
|
|
111
|
+
* Why it matters at all, measured on the owner's own 90 MB upload (2026-08-13): a phone
|
|
112
|
+
* MP4/MOV stores its `moov` index at the END of the file — that clip's began at byte
|
|
113
|
+
* 90,305,514 of 90,339,606 — and a browser cannot decode frame one until it has that index,
|
|
114
|
+
* so it downloads the whole file first. That is the "videos stay on a spinner for minutes"
|
|
115
|
+
* report. The derivative is a `-movflags +faststart` remux with the index at byte 32, and for
|
|
116
|
+
* h264+aac sources (which is what phones produce) it is a STREAM COPY: same pictures, no
|
|
117
|
+
* re-encode, nothing lost. It also drops the junk tracks iPhones attach — that clip's original
|
|
118
|
+
* carried an unknown audio stream and five data streams, which is a second, independent reason
|
|
119
|
+
* a browser refuses a `.mov`.
|
|
120
|
+
*
|
|
121
|
+
* 🔴 These are DERIVED, never stored on the row. A `web_key` column would let an app's catalog
|
|
122
|
+
* disagree with what binary-server actually holds — and the catalog lives on prod-ec2 while
|
|
123
|
+
* the bytes live on the Mac, so the two would drift the first time either side was restored
|
|
124
|
+
* from a backup.
|
|
125
|
+
*
|
|
126
|
+
* ── Why this was a SHARED module and not one app's ───────────────────────────────────
|
|
127
|
+
* The record, because it explains the shape this file still has. It was
|
|
128
|
+
* `@satellites/satellite-kit/video-derivatives`; `collections` is the only app in this
|
|
129
|
+
* generation that has it, and it is copied here rather than re-inlined into `storage.ts`.
|
|
130
|
+
*
|
|
131
|
+
* It was `apps/collections/src/server/storage.ts` first, and it worked, so `apps/family`
|
|
132
|
+
* shipped video with none of it: no faststart stream, no scrub previews, no processing state
|
|
133
|
+
* — the owner's 2026-08-13 note ("the ffmpeg processing, awesome scrubbing … should all be
|
|
134
|
+
* possible" in family, with collections named as the example to copy). A key contract shared
|
|
135
|
+
* with ANOTHER REPO is exactly the thing that must not be re-typed per app: a second copy of
|
|
136
|
+
* `${key}.web.mp4` is a silent no-preview the day binary-server renames anything.
|
|
137
|
+
*
|
|
138
|
+
* 🔴 What is shared here is the STRATEGY, never the media. Nothing in this module reaches
|
|
139
|
+
* across tenants — every function takes the caller's own store, and each app is its own
|
|
140
|
+
* binary-server tenant with its own signing key. family must never be able to resolve a
|
|
141
|
+
* collections object and vice versa; keeping this module store-agnostic is what keeps that
|
|
142
|
+
* true while the two apps share one pipeline.
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* The single still a gallery draws on a tile — binary-server's `.thumb.jpg`.
|
|
146
|
+
*
|
|
147
|
+
* ── 🔴 Why this is here, from the owner's report (2026-08-14) ────────────────────────
|
|
148
|
+
* *"videos are not showing with thumbnails in the family app keepsakes"*, with a screenshot
|
|
149
|
+
* of one album: eleven photographs with tiles and three videos — 129, 282 and 633 MB — drawn
|
|
150
|
+
* as a grey play triangle.
|
|
151
|
+
*
|
|
152
|
+
* Nothing was broken. binary-server's `runPoster` had always existed and was reachable only
|
|
153
|
+
* from its `library` strategy, while `family` and `collections` both submit `faststart`,
|
|
154
|
+
* which minted a remux and sprites and no still. With no picture to show, the gallery fell
|
|
155
|
+
* back to asking the BROWSER to decode the clip's own first frame — which works on a small
|
|
156
|
+
* file and does not happen at all on a 633 MB one over a home connection, because the browser
|
|
157
|
+
* must fetch and demux the front of the container first and quietly gives up.
|
|
158
|
+
*
|
|
159
|
+
* 🔴 **The sprite sheet is not a substitute and was rejected as one.** It is a GRID of frames,
|
|
160
|
+
* so drawing it on a tile shows a contact sheet, and using one cell means every consumer
|
|
161
|
+
* carries the VTT's geometry in order to crop it — a second, subtler copy of exactly the
|
|
162
|
+
* cross-repo duplication this module exists to prevent.
|
|
163
|
+
*
|
|
164
|
+
* 🔴 **The same name a still photograph's thumbnail gets, deliberately.** A consumer then has
|
|
165
|
+
* ONE rule — *the tile for anything is `<key>.thumb.jpg`, and its absence means not ready* —
|
|
166
|
+
* rather than one rule per media kind, and `.thumb.jpg` was already in binary-server's
|
|
167
|
+
* `DERIVATIVE_SUFFIXES`, so nothing about the key contract, the sweep's backlog arithmetic or
|
|
168
|
+
* the duplicate scan changes. A video and a photograph are different things to a person and
|
|
169
|
+
* the same thing to a grid.
|
|
170
|
+
*/
|
|
171
|
+
/**
|
|
172
|
+
* The relative sprite name binary-server writes into every VTT cue, which a consumer rewrites
|
|
173
|
+
* into a signed URL when it serves the track.
|
|
174
|
+
*
|
|
175
|
+
* 🔴 A literal shared with another repo and enforced by nothing but this comment. It is
|
|
176
|
+
* `buildSpriteVtt`'s `spriteName` default in binary-server's `ffmpegJobs.ts`. If that default
|
|
177
|
+
* ever changes, scrub previews go blank silently — a cue pointing at a name nothing serves
|
|
178
|
+
* produces no error, no console warning and no visible failure except an empty thumbnail
|
|
179
|
+
* strip. Each consumer's scrub-route test asserts a real binary-server-shaped VTT is
|
|
180
|
+
* rewritten, so the pin is behavioral rather than a hope.
|
|
181
|
+
*/
|
|
182
|
+
export declare const SPRITE_VTT_PLACEHOLDER = "sprites.jpg";
|
|
183
|
+
/**
|
|
184
|
+
* The largest scrub track an app will proxy. A VTT is one text cue per sampled frame and
|
|
185
|
+
* binary-server caps a sheet at a few hundred tiles, so an honest one is under 20 KB; this is
|
|
186
|
+
* a tripwire against proxying something that is not a VTT at all, on a box with
|
|
187
|
+
* `MemoryMax=300M`.
|
|
188
|
+
*/
|
|
189
|
+
export declare const MAX_SCRUB_VTT_BYTES: number;
|
|
190
|
+
/**
|
|
191
|
+
* Rewrite a binary-server scrub track so its cues point at a signed sprite URL.
|
|
192
|
+
*
|
|
193
|
+
* 🔴 The reference is replaced WHOLESALE rather than prefixed: a signed URL carries a query
|
|
194
|
+
* string, and a cue's `#xywh=` fragment must stay after it. Prefixing produces
|
|
195
|
+
* `…/sprite.jpg?token=…#xywh=0,0,160,90` only by accident of ordering, and gets it wrong the
|
|
196
|
+
* moment the signer appends a parameter.
|
|
197
|
+
*/
|
|
198
|
+
export declare const rewriteScrubVtt: (vtt: string, spriteUrl: string) => string;
|
|
199
|
+
/** Just enough of `BinaryStore` to resolve a derivative — so this module needs no store type. */
|
|
200
|
+
export interface DerivativeProbe {
|
|
201
|
+
meta(key: string): Promise<unknown | null>;
|
|
202
|
+
}
|
|
203
|
+
export interface VideoPlayback {
|
|
204
|
+
/**
|
|
205
|
+
* The faststart remux, when binary-server has finished writing it. Absent means play the
|
|
206
|
+
* original and say why — never fail.
|
|
207
|
+
*/
|
|
208
|
+
streamUrl?: string;
|
|
209
|
+
/** `"processing"` exactly when the remux is not there yet. */
|
|
210
|
+
processingStatus?: "processing";
|
|
211
|
+
/** True when a scrub track exists, so the caller can advertise its own proxy route. */
|
|
212
|
+
scrubReady: boolean;
|
|
213
|
+
/**
|
|
214
|
+
* The HLS master playlist, when a ladder exists — cursedbelt's `VideoPlayer` PREFERS this
|
|
215
|
+
* over {@link streamUrl}.
|
|
216
|
+
*
|
|
217
|
+
* 🔴 Absent is the NORMAL state and must stay cheap. Only some videos will ever have a
|
|
218
|
+
* ladder (it is a re-encode, unlike the faststart stream copy), so a consumer treats this
|
|
219
|
+
* exactly like every other derivative here: present means use it, absent means fall back,
|
|
220
|
+
* never an error.
|
|
221
|
+
*
|
|
222
|
+
* What it buys, given Range already works: a progressive MP4 cannot ADAPT. A weak
|
|
223
|
+
* connection stalls on a 23 Mbps master instead of dropping a rung, and each segment is a
|
|
224
|
+
* small object that caches whole at the edge rather than needing the big-object segment
|
|
225
|
+
* machinery.
|
|
226
|
+
*/
|
|
227
|
+
hlsUrl?: string;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Resolve what a video can offer RIGHT NOW: the faststart stream if it is ready, and whether
|
|
231
|
+
* a scrub track exists.
|
|
232
|
+
*
|
|
233
|
+
* 🔴 Best-effort throughout, deliberately. binary-server being slow, down, or out of disk must
|
|
234
|
+
* degrade this to "here are the original bytes", never break opening a file — a probe that
|
|
235
|
+
* throws would turn a working (if slow) video into a broken one, which is strictly worse than
|
|
236
|
+
* the problem the derivative exists to solve. An absent derivative is a NORMAL state.
|
|
237
|
+
*
|
|
238
|
+
* The signer is injected rather than taken as a store + options bag: the two consumers mint
|
|
239
|
+
* URLs under different privacy rules (collections signs a private item with a short,
|
|
240
|
+
* non-stable window; family signs by kind), and that decision must stay at the call site
|
|
241
|
+
* where the row's privacy is known.
|
|
242
|
+
*/
|
|
243
|
+
export declare function resolveVideoPlayback(store: DerivativeProbe, sourceKey: string, signStream: (key: string) => Promise<string>,
|
|
244
|
+
/**
|
|
245
|
+
* Signs a PREFIX-scoped token for an HLS tree, when the caller can serve one.
|
|
246
|
+
*
|
|
247
|
+
* Separate from `signStream` because it mints a different claim (`p`, not `k`): one token
|
|
248
|
+
* has to authorize the master playlist AND every rung index AND every segment under it,
|
|
249
|
+
* and re-signing per segment would mean a round trip to the app for each one. Optional so
|
|
250
|
+
* a consumer that has not wired the HLS route yet keeps working unchanged — it simply
|
|
251
|
+
* never advertises a ladder.
|
|
252
|
+
*/
|
|
253
|
+
signHlsTree?: (prefix: string) => Promise<string>): Promise<VideoPlayback>;
|