cursedbelt-server 4.18.1 → 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/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/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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/satellite-config` — the satellite CONFIGURATION contract, once: the fields
|
|
3
|
+
* every app's config carried, the data-dir convention, the test-runner guards, and the refusal
|
|
4
|
+
* of a production boot that cannot serve safely. Bun/Node only (`node:fs`); a Worker imports
|
|
5
|
+
* `cursedbelt-server/satellite-door`, which this re-exports whole.
|
|
6
|
+
*
|
|
7
|
+
* ## Why, since 4.19.0 (task 281)
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-09-23: nine `appConfig.ts` copies in five versions, plus `appData.ts`,
|
|
10
|
+
* `stageIdentity.ts` and `runtimeMode.ts` travelling with them. The file's whole job is
|
|
11
|
+
* `productionBlockers` + `assertUsable`, and the versions differed in exactly that job:
|
|
12
|
+
*
|
|
13
|
+
* · lineage B (`flix`, `station`, `patterns`) threw a plain `Error` — a misconfigured boot
|
|
14
|
+
* presented as a crash — where lineage A threw `cwip/env`'s `ConfigRefusalError` (exit 78);
|
|
15
|
+
* · `vault`'s trimmed copy dropped the extra-blockers argument, so it COULD not add one;
|
|
16
|
+
* · `vault`, `flix` and `station` lost the stage-identity override (`./satellite-door`);
|
|
17
|
+
* · lineage A's `appData.ts` called `forgeState({ HOME })`, which threw the environment away —
|
|
18
|
+
* so `$FORGE_STATE` was never read, and the state root was spelled with the generation's
|
|
19
|
+
* NAME in it (task 304). Here it comes from `cursedops/roots`, which reads `$FORGE_STATE` and
|
|
20
|
+
* otherwise the `forge.env` above this module — correct in a launchd job, in a worktree, and
|
|
21
|
+
* in the next generation with no edit — and REFUSES rather than guessing.
|
|
22
|
+
*
|
|
23
|
+
* The union of the protections is what is here. What an app still owns is its identity: its own
|
|
24
|
+
* fields, its `stakes` sentence, and its own blockers, passed as `extra` — never a fork of this.
|
|
25
|
+
*
|
|
26
|
+
* ## The one behaviour that is STRICTER than every copy
|
|
27
|
+
*
|
|
28
|
+
* The refusal fires when the instance is `live` — a release OR a production build — where lineage
|
|
29
|
+
* A asked only "is this a production build". Every `scripts/service.ts` sets
|
|
30
|
+
* `NODE_ENV=production`, so the two agree on every real boot today; `live` is the one that also
|
|
31
|
+
* refuses a release someone started without it.
|
|
32
|
+
*/
|
|
33
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { assertConfigUsable, readIntEnv, readOriginEnv, sessionSecretBlocker, } from "cwip/env";
|
|
37
|
+
import { forgeState } from "cursedops/roots";
|
|
38
|
+
import { DEV_AUTH_URL, isProductionRuntime, isRelease, isTestRuntime, LOCAL_AUTH_URL, parseEmailList, PROD_AUTH_URL, stageIdentityIsLocal, TEST_SCRATCH_DIR_NAME, } from "./door.js";
|
|
39
|
+
export * from "./door.js";
|
|
40
|
+
export { formatConfigBlockers, isConfigRefusal, sessionSecretBlocker } from "cwip/env";
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
// ── the state root, and the data directory under it ─────────────────────────────────────────
|
|
43
|
+
/** Where the generation keeps state: `options.stateRoot`, else `cursedops/roots`, else `null`. */
|
|
44
|
+
function stateRootOrNull(env, explicit) {
|
|
45
|
+
if (explicit)
|
|
46
|
+
return explicit;
|
|
47
|
+
return forgeState(HERE, env);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The generation's state root — every app's durable data lives under it.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 Throws rather than guessing: a function that invented `$HOME/.<something>` would write into
|
|
53
|
+
* a stranger's tree, and would be right for exactly one generation.
|
|
54
|
+
*/
|
|
55
|
+
export function stateRoot(options = {}) {
|
|
56
|
+
const root = stateRootOrNull(options.env ?? process.env, options.stateRoot);
|
|
57
|
+
if (root)
|
|
58
|
+
return root;
|
|
59
|
+
throw new Error("No generation state root: $FORGE_STATE is unset and no forge.env was found above this checkout.\n" +
|
|
60
|
+
" Source the generation's forge.env first, run from inside its checkout, or set APP_DATA_DIR.");
|
|
61
|
+
}
|
|
62
|
+
/** The scratch tree tests may write under — the one carve-out {@link assertTestSafeDbPath} allows. */
|
|
63
|
+
export function testScratchRoot(options = {}) {
|
|
64
|
+
return path.join(stateRoot(options), TEST_SCRATCH_DIR_NAME);
|
|
65
|
+
}
|
|
66
|
+
/** A deployed app's data directory: `<state root>/apps/<app>`. */
|
|
67
|
+
export function deployedDataDirFor(app, options = {}) {
|
|
68
|
+
return path.join(stateRoot(options), "apps", app);
|
|
69
|
+
}
|
|
70
|
+
/** Why a test was refused a real data directory. */
|
|
71
|
+
export function realDataDirRefusal(app) {
|
|
72
|
+
return (`[${app}] refusing to resolve a REAL data directory under a test runner.\n` +
|
|
73
|
+
"A test must never open the owner's live database — it is their credentials, notes and saves, " +
|
|
74
|
+
"and a test is allowed to delete rows.\n" +
|
|
75
|
+
'Pass an explicit path instead: ":memory:", a scratch directory, or set APP_DATA_DIR for this process.\n' +
|
|
76
|
+
"(If you are testing THIS resolution, set SATELLITE_ALLOW_REAL_DATA_DIR_IN_TEST=1.)");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Where this app's durable data lives. `APP_DATA_DIR` when the deploy assigned one; under a test
|
|
80
|
+
* runner, a REFUSAL (never the owner's live database); otherwise `<state root>/apps/<devDirName>`.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveAppDataDir(options) {
|
|
83
|
+
const env = options.env ?? process.env;
|
|
84
|
+
const assigned = env.APP_DATA_DIR?.trim();
|
|
85
|
+
if (assigned)
|
|
86
|
+
return assigned;
|
|
87
|
+
if (isTestRuntime(env) && env.SATELLITE_ALLOW_REAL_DATA_DIR_IN_TEST !== "1") {
|
|
88
|
+
throw new Error(realDataDirRefusal(options.app));
|
|
89
|
+
}
|
|
90
|
+
return path.join(stateRoot(options), "apps", options.devDirName ?? options.app);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `bun:sqlite`'s `create: true` creates the file, never the directory above it. Swallowed on
|
|
94
|
+
* purpose: the open that follows names the real problem.
|
|
95
|
+
*/
|
|
96
|
+
export function ensureDbDirectory(dbPath) {
|
|
97
|
+
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file::memory:"))
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// see above
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Why a test was refused a database inside the state root. */
|
|
107
|
+
export function realDbRefusal(dbPath) {
|
|
108
|
+
return (`Refusing to open "${dbPath}" from a test.\n` +
|
|
109
|
+
"That path is inside this generation's state root — the owner's REAL data, not a fixture. " +
|
|
110
|
+
"A test may delete rows; this one would delete theirs.\n" +
|
|
111
|
+
`Use ":memory:", or a directory under the state root's ${TEST_SCRATCH_DIR_NAME} (allowed on purpose).`);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Under a test runner, refuse a database path inside the state root — except under its scratch
|
|
115
|
+
* tree. A no-op outside tests, for `:memory:`, and where no state root can be found (there is then
|
|
116
|
+
* no owner data at a known place to protect).
|
|
117
|
+
*/
|
|
118
|
+
export function assertTestSafeDbPath(dbPath, options = {}) {
|
|
119
|
+
const env = options.env ?? process.env;
|
|
120
|
+
if (!isTestRuntime(env))
|
|
121
|
+
return;
|
|
122
|
+
if (env.SATELLITE_ALLOW_REAL_DATA_DIR_IN_TEST === "1")
|
|
123
|
+
return;
|
|
124
|
+
if (dbPath === ":memory:" || dbPath === "")
|
|
125
|
+
return;
|
|
126
|
+
const root = stateRootOrNull(env, options.stateRoot);
|
|
127
|
+
if (!root)
|
|
128
|
+
return;
|
|
129
|
+
const resolved = path.resolve(dbPath);
|
|
130
|
+
const scratch = path.join(root, TEST_SCRATCH_DIR_NAME);
|
|
131
|
+
if (resolved.startsWith(`${scratch}${path.sep}`))
|
|
132
|
+
return;
|
|
133
|
+
if (resolved === root || resolved.startsWith(`${root}${path.sep}`))
|
|
134
|
+
throw new Error(realDbRefusal(dbPath));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read the fields every satellite needs. An app spreads the result into its own config and adds
|
|
138
|
+
* what is genuinely its own.
|
|
139
|
+
*/
|
|
140
|
+
export function readSatelliteConfig(spec) {
|
|
141
|
+
const env = spec.env ?? process.env;
|
|
142
|
+
const app = spec.app;
|
|
143
|
+
const prefix = spec.envPrefix ?? app.toUpperCase();
|
|
144
|
+
const v = (suffix) => `${prefix}_${suffix}`;
|
|
145
|
+
const deployed = isRelease(env);
|
|
146
|
+
const dataDir = spec.dataDir ?? resolveAppDataDir({ app, env, devDirName: spec.devDirName, stateRoot: spec.stateRoot });
|
|
147
|
+
const port = readIntEnv(env, "PORT", spec.defaultPort, app);
|
|
148
|
+
// 🔴 Origin defaults key on `live`, NOT on `deployed`: a production build run without a
|
|
149
|
+
// release stamp (`preview:prod`, a gate's artifact smoke) would otherwise point its JWKS fetch
|
|
150
|
+
// and grant redirect at a DEV auth server.
|
|
151
|
+
const live = deployed || isProductionRuntime(env);
|
|
152
|
+
const originFallback = spec.publicUrlFallback
|
|
153
|
+
? spec.publicUrlFallback({ port, deployed, live })
|
|
154
|
+
: live
|
|
155
|
+
? (spec.publicUrl ?? `https://${app}.cursedalchemy.com`)
|
|
156
|
+
: (spec.devPublicUrl ?? `http://localhost:${spec.defaultPort}`);
|
|
157
|
+
const sso = spec.sso ?? true;
|
|
158
|
+
return {
|
|
159
|
+
app,
|
|
160
|
+
port,
|
|
161
|
+
deployed,
|
|
162
|
+
live,
|
|
163
|
+
dataDir,
|
|
164
|
+
dbPath: env[v("DB_PATH")]?.trim() || path.join(dataDir, spec.dbFile ?? `${app}.sqlite`),
|
|
165
|
+
sessionsDbPath: path.join(dataDir, "sessions.sqlite"),
|
|
166
|
+
sessionSecret: env[v("SESSION_SECRET")]?.trim() || devSessionSecret(dataDir, live),
|
|
167
|
+
sessionTtlSeconds: readIntEnv(env, v("SESSION_TTL_SECONDS"), spec.sessionTtlSeconds, app),
|
|
168
|
+
publicUrl: readOriginEnv(env, v("PUBLIC_URL"), originFallback),
|
|
169
|
+
// 🔴 A stage's accounts are the LOCAL ones, and this is FORCED rather than defaulted: it
|
|
170
|
+
// ignores `<PREFIX>_AUTH_URL`, because an app's deploy secrets are what a stage boots with,
|
|
171
|
+
// and one stray prod line there would silently un-isolate every stage of that app — test
|
|
172
|
+
// sign-ins appearing in the owner's real registry, invisible from inside the run.
|
|
173
|
+
authUrl: !sso
|
|
174
|
+
? ""
|
|
175
|
+
: stageIdentityIsLocal(env)
|
|
176
|
+
? LOCAL_AUTH_URL
|
|
177
|
+
: readOriginEnv(env, v("AUTH_URL"), live ? PROD_AUTH_URL : DEV_AUTH_URL),
|
|
178
|
+
ssoPublicKeyPem: sso ? env[v("SSO_PUBLIC_KEY_PEM")]?.trim() || "" : "",
|
|
179
|
+
env,
|
|
180
|
+
envPrefix: prefix,
|
|
181
|
+
ownerEmail: (env[v("OWNER_EMAIL")]?.trim() || spec.ownerEmail).toLowerCase(),
|
|
182
|
+
extraAllowedEmails: (spec.extraEmails ?? true) ? parseEmailList(env[v("EXTRA_ALLOWED_EMAILS")]) : [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The file the dev session secret is remembered in, inside the app's data dir. */
|
|
186
|
+
export const DEV_SESSION_SECRET_FILE = "dev-session-secret";
|
|
187
|
+
/**
|
|
188
|
+
* The session-cookie key when no `<PREFIX>_SESSION_SECRET` is set.
|
|
189
|
+
*
|
|
190
|
+
* In DEV it is a FILE, not a per-boot UUID: the session rows are durable, so a per-boot key turns
|
|
191
|
+
* every restart into "sign-in just shows the button again" (owner report 2026-08-25 — seven good
|
|
192
|
+
* session rows in 22 seconds, not one usable). When `live` it stays a throwaway, because the
|
|
193
|
+
* refusal below forbids that boot and writing a key to disk would turn the refusal into a silent
|
|
194
|
+
* success with a credential nobody chose.
|
|
195
|
+
*/
|
|
196
|
+
export function devSessionSecret(dataDir, live) {
|
|
197
|
+
if (live)
|
|
198
|
+
return crypto.randomUUID();
|
|
199
|
+
const file = path.join(dataDir, DEV_SESSION_SECRET_FILE);
|
|
200
|
+
try {
|
|
201
|
+
const existing = readFileSync(file, "utf8").trim();
|
|
202
|
+
if (existing)
|
|
203
|
+
return existing;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// none yet
|
|
207
|
+
}
|
|
208
|
+
const minted = crypto.randomUUID();
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(dataDir, { recursive: true });
|
|
211
|
+
writeFileSync(file, `${minted}\n`, "utf8");
|
|
212
|
+
chmodSync(file, 0o600);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// an unwritable dev dir still boots; it just signs people out on restart
|
|
216
|
+
}
|
|
217
|
+
return minted;
|
|
218
|
+
}
|
|
219
|
+
/** The blockers every satellite shares — today exactly one, the session secret. */
|
|
220
|
+
export function satelliteBlockers(config) {
|
|
221
|
+
const name = `${config.envPrefix}_SESSION_SECRET`;
|
|
222
|
+
return config.env[name]?.trim() ? [] : [sessionSecretBlocker(name)];
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The binary-server signing key — for every app whose files live there.
|
|
226
|
+
*
|
|
227
|
+
* 🔴 The failure it refuses hides better than any other: every list renders, `/healthz` is 200,
|
|
228
|
+
* and only the file bytes 403 — a broken gallery, not a missing variable. `family` and `roms` had
|
|
229
|
+
* no such refusal (task 281's census), so a release missing the key booted "healthy".
|
|
230
|
+
*
|
|
231
|
+
* @param what What the key fetches, for the sentence — "images and video", "audio", "portraits".
|
|
232
|
+
*/
|
|
233
|
+
export function fileTokenBlocker(config, what, name = "FILE_TOKEN_PRIVATE_KEY") {
|
|
234
|
+
if (config.env[name]?.trim())
|
|
235
|
+
return null;
|
|
236
|
+
return {
|
|
237
|
+
name,
|
|
238
|
+
detail: `signs the binary-server tokens that fetch ${what}. Without it every page lists and not one file loads — a failure that looks like a broken app rather than like missing configuration.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Every reason, one line each — what a `/healthz` or a `--check` prints. */
|
|
242
|
+
export function satelliteBlockerLines(config, extra = []) {
|
|
243
|
+
return [...satelliteBlockers(config), ...extra.filter((b) => b !== null)].map((b) => `${b.name} — ${b.detail}`);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Refuse a `live` boot that cannot serve safely, naming every reason at once — `cwip/env`'s
|
|
247
|
+
* `ConfigRefusalError`, exit 78, so a misconfiguration never reads as a crash. A no-op in dev.
|
|
248
|
+
*
|
|
249
|
+
* @param stakes What is at risk, as one clause. It is what makes the failure legible at 3am.
|
|
250
|
+
* @param extra The app's own blockers; `null` entries (an optional blocker that passed) are skipped.
|
|
251
|
+
*/
|
|
252
|
+
export function assertSatelliteUsable(config, stakes, extra = []) {
|
|
253
|
+
assertConfigUsable({
|
|
254
|
+
app: config.app,
|
|
255
|
+
stakes,
|
|
256
|
+
blockers: [...satelliteBlockers(config), ...extra.filter((b) => b !== null)],
|
|
257
|
+
isProduction: config.live,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
/** Anything with the shape of an environment — `process.env`, a Worker's `env`, a test literal. */
|
|
31
|
+
export type EnvLike = Readonly<Record<string, string | undefined>>;
|
|
32
|
+
/** The accounts app (`apps/auth`) in production. */
|
|
33
|
+
export declare const PROD_AUTH_URL = "https://auth.cursedalchemy.com";
|
|
34
|
+
/** The accounts app's dev shell. */
|
|
35
|
+
export declare const DEV_AUTH_URL = "http://localhost:3309";
|
|
36
|
+
/**
|
|
37
|
+
* The LOCAL accounts door a stage signs in against — loopback, never the public hostname, so a
|
|
38
|
+
* stage's test sign-ins never write a row into the owner's real accounts registry.
|
|
39
|
+
*/
|
|
40
|
+
export declare const LOCAL_AUTH_URL = "http://127.0.0.1:3309";
|
|
41
|
+
/** `APP_STAGE_IDENTITY=local` — the marker a stage boots with. */
|
|
42
|
+
export declare const STAGE_IDENTITY_ENV = "APP_STAGE_IDENTITY";
|
|
43
|
+
/** The one directory under the state root that is fixtures by construction. */
|
|
44
|
+
export declare const TEST_SCRATCH_DIR_NAME = "test-scratch.noindex";
|
|
45
|
+
/**
|
|
46
|
+
* Every marker that means "a test runner is in charge". A spec that spawns a REAL server strips
|
|
47
|
+
* exactly these ({@link withoutTestRuntimeMarkers}), so a fourth marker added here is stripped by
|
|
48
|
+
* everyone for free.
|
|
49
|
+
*/
|
|
50
|
+
export declare const TEST_RUNTIME_ENV_KEYS: readonly ["NODE_ENV", "BUN_TEST", "SATELLITE_TEST_RUNTIME"];
|
|
51
|
+
/** Is this instance a stage whose identity must stay LOCAL? */
|
|
52
|
+
export declare function stageIdentityIsLocal(env?: EnvLike): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Is this origin an ephemeral STAGE — `<app>-stage.cursedalchemy.com`?
|
|
55
|
+
*
|
|
56
|
+
* Derived from the hostname, never configured: a `-stage` name in the fleet's own zone cannot be
|
|
57
|
+
* typed into existence (only `stage up` creates the record, behind one Access application), so a
|
|
58
|
+
* production release cannot acquire one by a typo in a secrets file.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isStageOrigin(publicUrl: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Is a test runner in charge? The three markers, and `SATELLITE_TEST_RUNTIME` because the React
|
|
63
|
+
* preloads force `NODE_ENV=development` and erase Bun's own `test`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isTestRuntime(env?: EnvLike): boolean;
|
|
66
|
+
/** A copy of `env` with every test-runtime marker removed — what a spec hands a REAL server it spawns. */
|
|
67
|
+
export declare function withoutTestRuntimeMarkers(env: EnvLike): Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* IS THIS A RELEASE? One question, one variable.
|
|
70
|
+
*
|
|
71
|
+
* `APP_RELEASE` is stamped by the deploy and by nothing else; `0`/`false` is an explicit no (a
|
|
72
|
+
* stage plan and a floor test both rely on that — lineage B ignored it and could not). Without
|
|
73
|
+
* the stamp it falls back to `APP_DATA_DIR`, minus the test-scratch tree, because a release
|
|
74
|
+
* shipped before the stamp existed must still answer yes: flipping it to no would open the smoke
|
|
75
|
+
* extras on the open internet. The fallback retires itself as each app redeploys.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isRelease(env?: EnvLike): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Is this a DEV runtime? Vite's `import.meta.env.DEV` when a bundle carries it, else
|
|
80
|
+
* `NODE_ENV !== "production"` of the env given (or the ambient one).
|
|
81
|
+
*/
|
|
82
|
+
export declare function isDevRuntime(env?: EnvLike): boolean;
|
|
83
|
+
/** The inverse of {@link isDevRuntime} — what `assertSatelliteUsable`'s refusal keys on with `deployed`. */
|
|
84
|
+
export declare function isProductionRuntime(env?: EnvLike): boolean;
|
|
85
|
+
/** `a@x, ,b@x` → `["a@x", "b@x"]`. The one parse for every `*_EXTRA_ALLOWED_EMAILS`. */
|
|
86
|
+
export declare function parseEmailList(raw: string | undefined): string[];
|
|
87
|
+
/** The slice of a config the door rules read. */
|
|
88
|
+
export interface DoorConfig {
|
|
89
|
+
/** A release — {@link isRelease}. */
|
|
90
|
+
deployed: boolean;
|
|
91
|
+
/** This instance's own origin, when known. A stage is recognised by it. */
|
|
92
|
+
publicUrl?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* May extra accounts widen the door on THIS instance? Only off a release, or on a stage.
|
|
96
|
+
*
|
|
97
|
+
* 🔴 Keyed on `deployed`, not on the origin and not on loopback: a gate's artifact smoke runs the
|
|
98
|
+
* PRODUCTION build on `127.0.0.1`, so `live` is true there and the origin falls back to the prod
|
|
99
|
+
* hostname — keying on either would leave the extras inert in the one place they exist to work.
|
|
100
|
+
* Derived rather than configured: a boolean meaning "let someone else in" is one typo from true
|
|
101
|
+
* in production.
|
|
102
|
+
*/
|
|
103
|
+
export declare function extraEmailsApply(config: DoorConfig): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The addresses this instance opens for, owner first — what an owner-only app hands its SSO
|
|
106
|
+
* consumer's `allowedEmails`. One function, so the server, `/healthz` and the tests cannot
|
|
107
|
+
* disagree about the answer.
|
|
108
|
+
*/
|
|
109
|
+
export declare function allowedEmailsFor(config: DoorConfig & {
|
|
110
|
+
ownerEmail: string;
|
|
111
|
+
extraAllowedEmails: readonly string[];
|
|
112
|
+
}): string[];
|
|
@@ -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. */
|