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.
Files changed (80) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/d1/pullD1.js +16 -3
  12. package/dist/server/engagement/api.d.ts +71 -0
  13. package/dist/server/engagement/api.js +84 -0
  14. package/dist/server/engagement/env.d.ts +18 -0
  15. package/dist/server/engagement/env.js +52 -0
  16. package/dist/server/engagement/index.d.ts +55 -0
  17. package/dist/server/engagement/index.js +55 -0
  18. package/dist/server/engagement/places.d.ts +22 -0
  19. package/dist/server/engagement/places.js +63 -0
  20. package/dist/server/engagement/policy.d.ts +168 -0
  21. package/dist/server/engagement/policy.js +202 -0
  22. package/dist/server/engagement/store.d.ts +92 -0
  23. package/dist/server/engagement/store.js +223 -0
  24. package/dist/server/engagement/summary.d.ts +102 -0
  25. package/dist/server/engagement/summary.js +127 -0
  26. package/dist/server/engagement/types.d.ts +42 -0
  27. package/dist/server/engagement/types.js +12 -0
  28. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  29. package/dist/server/maps-budget/mapsBudget.js +193 -0
  30. package/dist/server/satellite/config.d.ts +173 -0
  31. package/dist/server/satellite/config.js +259 -0
  32. package/dist/server/satellite/door.d.ts +112 -0
  33. package/dist/server/satellite/door.js +149 -0
  34. package/dist/server/storage/binaryStore.d.ts +18 -0
  35. package/dist/server/storage/binaryStore.js +32 -1
  36. package/dist/server/storage/derivatives.d.ts +253 -0
  37. package/dist/server/storage/derivatives.js +266 -0
  38. package/dist/server/storage/uploadSession.d.ts +75 -0
  39. package/dist/server/storage/uploadSession.js +74 -0
  40. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  41. package/docs/activity.md +43 -0
  42. package/docs/engagement.md +47 -0
  43. package/docs/notifications.md +43 -0
  44. package/docs/retention.md +81 -0
  45. package/docs/skipped-tests.md +19 -0
  46. package/package.json +46 -9
  47. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  48. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  49. package/src/server/activity/index.ts +2 -1
  50. package/src/server/auth/passwordCost.spec.ts +42 -0
  51. package/src/server/auth/passwordCost.ts +87 -0
  52. package/src/server/bench/index.ts +13 -0
  53. package/src/server/bench/tail.spec.ts +126 -0
  54. package/src/server/bench/tail.ts +237 -0
  55. package/src/server/d1/index.ts +9 -9
  56. package/src/server/d1/pullD1.spec.ts +20 -0
  57. package/src/server/d1/pullD1.ts +18 -2
  58. package/src/server/engagement/api.ts +119 -0
  59. package/src/server/engagement/engagement.spec.ts +462 -0
  60. package/src/server/engagement/env.ts +73 -0
  61. package/src/server/engagement/index.ts +92 -0
  62. package/src/server/engagement/places.ts +76 -0
  63. package/src/server/engagement/policy.ts +250 -0
  64. package/src/server/engagement/store.ts +272 -0
  65. package/src/server/engagement/summary.ts +216 -0
  66. package/src/server/engagement/types.ts +61 -0
  67. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  68. package/src/server/maps-budget/mapsBudget.ts +304 -0
  69. package/src/server/satellite/config.ts +389 -0
  70. package/src/server/satellite/door.ts +169 -0
  71. package/src/server/satellite/satellite.spec.ts +161 -0
  72. package/src/server/storage/binaryStore.ts +31 -1
  73. package/src/server/storage/derivatives.spec.ts +125 -0
  74. package/src/server/storage/derivatives.ts +329 -0
  75. package/src/server/storage/uploadSession.spec.ts +132 -0
  76. package/src/server/storage/uploadSession.ts +114 -0
  77. package/dist/server/d1/kysely.d.ts +0 -56
  78. package/dist/server/d1/kysely.js +0 -138
  79. package/src/server/d1/kysely.spec.ts +0 -145
  80. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,173 @@
1
+ import { type ConfigBlocker } from "cwip/env";
2
+ import { type EnvLike } from "./door.js";
3
+ export * from "./door.js";
4
+ export type { ConfigBlocker } from "cwip/env";
5
+ export { formatConfigBlockers, isConfigRefusal, sessionSecretBlocker } from "cwip/env";
6
+ /**
7
+ * The generation's state root — every app's durable data lives under it.
8
+ *
9
+ * 🔴 Throws rather than guessing: a function that invented `$HOME/.<something>` would write into
10
+ * a stranger's tree, and would be right for exactly one generation.
11
+ */
12
+ export declare function stateRoot(options?: {
13
+ env?: EnvLike;
14
+ stateRoot?: string;
15
+ }): string;
16
+ /** The scratch tree tests may write under — the one carve-out {@link assertTestSafeDbPath} allows. */
17
+ export declare function testScratchRoot(options?: {
18
+ env?: EnvLike;
19
+ stateRoot?: string;
20
+ }): string;
21
+ /** A deployed app's data directory: `<state root>/apps/<app>`. */
22
+ export declare function deployedDataDirFor(app: string, options?: {
23
+ env?: EnvLike;
24
+ stateRoot?: string;
25
+ }): string;
26
+ /** Why a test was refused a real data directory. */
27
+ export declare function realDataDirRefusal(app: string): string;
28
+ export interface ResolveAppDataDirOptions {
29
+ /** The app key — `vault`, `roms`, … Names the directory. */
30
+ app: string;
31
+ env?: EnvLike;
32
+ /**
33
+ * The dev shell's directory name, when it should differ from `app`. Defaults to `app`:
34
+ * RENAMING a dev directory makes that shell boot EMPTY, which reads exactly like data loss.
35
+ * Opt in per app with a reason (`vault` passes `vault-dev`).
36
+ */
37
+ devDirName?: string;
38
+ /** Tests of this module: the state root to resolve under. */
39
+ stateRoot?: string;
40
+ }
41
+ /**
42
+ * Where this app's durable data lives. `APP_DATA_DIR` when the deploy assigned one; under a test
43
+ * runner, a REFUSAL (never the owner's live database); otherwise `<state root>/apps/<devDirName>`.
44
+ */
45
+ export declare function resolveAppDataDir(options: ResolveAppDataDirOptions): string;
46
+ /**
47
+ * `bun:sqlite`'s `create: true` creates the file, never the directory above it. Swallowed on
48
+ * purpose: the open that follows names the real problem.
49
+ */
50
+ export declare function ensureDbDirectory(dbPath: string): void;
51
+ /** Why a test was refused a database inside the state root. */
52
+ export declare function realDbRefusal(dbPath: string): string;
53
+ /**
54
+ * Under a test runner, refuse a database path inside the state root — except under its scratch
55
+ * tree. A no-op outside tests, for `:memory:`, and where no state root can be found (there is then
56
+ * no owner data at a known place to protect).
57
+ */
58
+ export declare function assertTestSafeDbPath(dbPath: string, options?: {
59
+ env?: EnvLike;
60
+ stateRoot?: string;
61
+ }): void;
62
+ export interface SatelliteConfigSpec {
63
+ /** App key — `roms`. Prefixes errors, names the data dir and the `<PREFIX>_` vars. */
64
+ app: string;
65
+ /** `PORT`'s fallback. */
66
+ defaultPort: number;
67
+ /** Session lifetime fallback, seconds. */
68
+ sessionTtlSeconds: number;
69
+ /**
70
+ * The ONE person an owner-only app opens for, unless `<PREFIX>_OWNER_EMAIL` says otherwise.
71
+ * Required: this library is published and does not know the owner.
72
+ */
73
+ ownerEmail: string;
74
+ /** Env-var prefix. Defaults to the app key upper-cased. */
75
+ envPrefix?: string;
76
+ /** The sqlite file inside the data dir. Defaults to `<app>.sqlite`. */
77
+ dbFile?: string;
78
+ /** This app's production origin. Defaults to `https://<app>.cursedalchemy.com`. */
79
+ publicUrl?: string;
80
+ /** This app's dev origin — the vite port, which is NOT `defaultPort`. */
81
+ devPublicUrl?: string;
82
+ /**
83
+ * Compute the origin fallback yourself when the prod/dev pair is not the whole story
84
+ * (`vault`: a loopback release is `deployed` and its origin is `127.0.0.1`, not the prod
85
+ * hostname). `<PREFIX>_PUBLIC_URL` still wins.
86
+ */
87
+ publicUrlFallback?: (ctx: {
88
+ port: number;
89
+ deployed: boolean;
90
+ live: boolean;
91
+ }) => string;
92
+ /** The dev shell's data-dir name — see {@link ResolveAppDataDirOptions.devDirName}. */
93
+ devDirName?: string;
94
+ /** Take this data dir instead of resolving one — the escape hatch, not the road. */
95
+ dataDir?: string;
96
+ /** Whether the app signs in through the accounts app. Default `true`; `patterns` has its own password. */
97
+ sso?: boolean;
98
+ /**
99
+ * Whether `<PREFIX>_EXTRA_ALLOWED_EMAILS` is read at all. Default `true`. An app that must only
100
+ * ever open for its owner (`vault`) passes `false`, and the list is always `[]`.
101
+ */
102
+ extraEmails?: boolean;
103
+ env?: NodeJS.ProcessEnv;
104
+ /** Tests: the state root to resolve under. */
105
+ stateRoot?: string;
106
+ }
107
+ /** The fields every satellite's config carried, under the names they all used. */
108
+ export interface SatelliteConfig {
109
+ app: string;
110
+ port: number;
111
+ /** A release — see `isRelease`. */
112
+ deployed: boolean;
113
+ /** A release OR a production build — what origin defaults and the refusal key on. */
114
+ live: boolean;
115
+ dataDir: string;
116
+ dbPath: string;
117
+ /** Sessions get their own file, so the credential surface is not a table in the app database. */
118
+ sessionsDbPath: string;
119
+ /** A remembered per-dev-tree secret in dev; production REQUIRES a stable one (a blocker). */
120
+ sessionSecret: string;
121
+ sessionTtlSeconds: number;
122
+ /** THIS app's absolute origin — where SSO grants may be redirected. */
123
+ publicUrl: string;
124
+ /** The accounts app's origin; `""` when `sso: false`. */
125
+ authUrl: string;
126
+ /** Static SSO verification key (SPKI PEM); `""` when unset or `sso: false`. */
127
+ ssoPublicKeyPem: string;
128
+ env: NodeJS.ProcessEnv;
129
+ /** `<PREFIX>` — so an app names its extra vars without repeating it. */
130
+ envPrefix: string;
131
+ ownerEmail: string;
132
+ /** Admitted in addition to the owner — only where `extraEmailsApply` says so. */
133
+ extraAllowedEmails: string[];
134
+ }
135
+ /**
136
+ * Read the fields every satellite needs. An app spreads the result into its own config and adds
137
+ * what is genuinely its own.
138
+ */
139
+ export declare function readSatelliteConfig(spec: SatelliteConfigSpec): SatelliteConfig;
140
+ /** The file the dev session secret is remembered in, inside the app's data dir. */
141
+ export declare const DEV_SESSION_SECRET_FILE = "dev-session-secret";
142
+ /**
143
+ * The session-cookie key when no `<PREFIX>_SESSION_SECRET` is set.
144
+ *
145
+ * In DEV it is a FILE, not a per-boot UUID: the session rows are durable, so a per-boot key turns
146
+ * every restart into "sign-in just shows the button again" (owner report 2026-08-25 — seven good
147
+ * session rows in 22 seconds, not one usable). When `live` it stays a throwaway, because the
148
+ * refusal below forbids that boot and writing a key to disk would turn the refusal into a silent
149
+ * success with a credential nobody chose.
150
+ */
151
+ export declare function devSessionSecret(dataDir: string, live: boolean): string;
152
+ /** The blockers every satellite shares — today exactly one, the session secret. */
153
+ export declare function satelliteBlockers(config: Pick<SatelliteConfig, "env" | "envPrefix">): ConfigBlocker[];
154
+ /**
155
+ * The binary-server signing key — for every app whose files live there.
156
+ *
157
+ * 🔴 The failure it refuses hides better than any other: every list renders, `/healthz` is 200,
158
+ * and only the file bytes 403 — a broken gallery, not a missing variable. `family` and `roms` had
159
+ * no such refusal (task 281's census), so a release missing the key booted "healthy".
160
+ *
161
+ * @param what What the key fetches, for the sentence — "images and video", "audio", "portraits".
162
+ */
163
+ export declare function fileTokenBlocker(config: Pick<SatelliteConfig, "env">, what: string, name?: string): ConfigBlocker | null;
164
+ /** Every reason, one line each — what a `/healthz` or a `--check` prints. */
165
+ export declare function satelliteBlockerLines(config: Pick<SatelliteConfig, "env" | "envPrefix">, extra?: readonly (ConfigBlocker | null)[]): string[];
166
+ /**
167
+ * Refuse a `live` boot that cannot serve safely, naming every reason at once — `cwip/env`'s
168
+ * `ConfigRefusalError`, exit 78, so a misconfiguration never reads as a crash. A no-op in dev.
169
+ *
170
+ * @param stakes What is at risk, as one clause. It is what makes the failure legible at 3am.
171
+ * @param extra The app's own blockers; `null` entries (an optional blocker that passed) are skipped.
172
+ */
173
+ export declare function assertSatelliteUsable(config: Pick<SatelliteConfig, "app" | "env" | "envPrefix" | "live">, stakes: string, extra?: readonly (ConfigBlocker | null)[]): void;
@@ -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[];