cursedbelt-server 4.22.0 → 4.24.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 (43) hide show
  1. package/README.md +8 -0
  2. package/dist/server/bench/budget.d.ts +50 -0
  3. package/dist/server/bench/budget.js +49 -1
  4. package/dist/server/bench/index.d.ts +1 -1
  5. package/dist/server/bench/index.js +1 -1
  6. package/dist/server/metrics/metricsBuffer.d.ts +10 -0
  7. package/dist/server/metrics/metricsBuffer.js +11 -3
  8. package/dist/server/metrics/requestPlace.d.ts +48 -0
  9. package/dist/server/metrics/requestPlace.js +77 -0
  10. package/dist/server/metrics/telemetrySink.d.ts +6 -1
  11. package/dist/server/metrics/telemetrySink.js +14 -2
  12. package/dist/server/middleware/requestLogger.d.ts +8 -0
  13. package/dist/server/middleware/requestLogger.js +4 -1
  14. package/dist/server/requestLog/requestMetrics.d.ts +11 -2
  15. package/dist/server/requestLog/requestMetrics.js +22 -4
  16. package/dist/server/telemetry.d.ts +1 -0
  17. package/dist/server/telemetry.js +1 -0
  18. package/dist/server/worker-argon2/argon2.d.ts +11 -0
  19. package/dist/server/worker-argon2/argon2.js +71 -0
  20. package/dist/server/worker-argon2/index.d.ts +9 -0
  21. package/dist/server/worker-argon2/index.js +9 -0
  22. package/dist/server/worker-argon2/password.d.ts +26 -0
  23. package/dist/server/worker-argon2/password.js +164 -0
  24. package/package.json +8 -1
  25. package/src/server/bench/budget.spec.ts +38 -0
  26. package/src/server/bench/budget.ts +58 -1
  27. package/src/server/bench/cpuBudget.spec.ts +15 -3
  28. package/src/server/bench/index.ts +1 -0
  29. package/src/server/metrics/metricsBuffer.ts +22 -2
  30. package/src/server/metrics/requestPlace.spec.ts +142 -0
  31. package/src/server/metrics/requestPlace.ts +92 -0
  32. package/src/server/metrics/telemetrySink.spec.ts +2 -1
  33. package/src/server/metrics/telemetrySink.ts +14 -2
  34. package/src/server/middleware/requestLogger.ts +12 -1
  35. package/src/server/requestLog/requestMetrics.spec.ts +73 -0
  36. package/src/server/requestLog/requestMetrics.ts +37 -4
  37. package/src/server/telemetry.ts +5 -0
  38. package/src/server/telemetryIsWorkerSafe.spec.ts +17 -0
  39. package/src/server/worker-argon2/argon2.ts +85 -0
  40. package/src/server/worker-argon2/index.spec.ts +162 -0
  41. package/src/server/worker-argon2/index.ts +9 -0
  42. package/src/server/worker-argon2/password.ts +185 -0
  43. package/src/server/worker-argon2/wasm.d.ts +9 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * argon2id as WebAssembly the Worker was HANDED, never WebAssembly it compiles.
3
+ *
4
+ * ## 🔴 Why this file exists: one unlock was 8.5–15 seconds of billed CPU
5
+ *
6
+ * `collections` averaged **489 CPU-ms per request on 2026-09-22 with a 15.2 s p99** (Cloudflare
7
+ * GraphQL, `workersInvocationsAdaptive`). Every second of it was `POST /__lock/unlock`: a real
8
+ * `wrangler tail` of the stage walk on 2026-09-23 read **8,519 CPU-ms and 10.4 s wall** for that
9
+ * one request and 10–38 ms for each of the other eleven. The lock verifies every account's
10
+ * argon2id hash (no early exit — `cursedbelt-server/master-lock` explains the timing oracle) plus
11
+ * a timing equalizer, so production's two accounts are three to four derivations per unlock, and
12
+ * `@noble/hashes`' pure-JS argon2id costs ~2.8 s each on workerd — five times the 562 ms it
13
+ * costs on this Mac. The owner waited ten seconds at the lock page, and a third account would
14
+ * have put the unlock within reach of the Worker's 30 s CPU ceiling, where it is killed and
15
+ * reads as a wrong password.
16
+ *
17
+ * ## 🔴 Why this shape: workerd refuses to COMPILE WebAssembly, not to RUN it
18
+ *
19
+ * `hash-wasm` failed here on 2026-09-18 with `Wasm code generation disallowed by embedder`
20
+ * because it compiles bytes it carries inline (`./password.ts` has the whole story). A
21
+ * `.wasm` file that is a STATIC import is different: wrangler bundles it as a
22
+ * `CompiledWasm` module and the runtime hands this code a ready `WebAssembly.Module`, which
23
+ * `WebAssembly.instantiate(module, imports)` may instantiate. `argon2id` (openpgpjs, MIT) ships
24
+ * its two binaries as plain files and lets the caller do the instantiating, which is exactly the
25
+ * seam a Worker needs. Its default entry inlines base64 and compiles it — so it is never imported.
26
+ *
27
+ * Under Bun (every test, and the Mac) the same import is a PATH STRING, so the bytes are read
28
+ * and compiled here — which Bun allows. `index.spec.ts` drives the Worker's branch too, with
29
+ * `WebAssembly.compile` made to throw exactly as workerd throws.
30
+ *
31
+ * ## Why it lives in `cursedbelt-server` (4.24.0)
32
+ *
33
+ * It was born in `apps/collections/worker/argon2.ts` (commit 7996ded) while `apps/vault` and
34
+ * `apps/patterns` still carried the pure-JS copy, so every sign-in there billed seconds. One copy
35
+ * here, `argon2id` a pinned dependency of this package: its two `.wasm` files ship in its own
36
+ * `files`, and wrangler — which resolves this subpath's `import` condition, `dist/…/argon2.js`,
37
+ * where tsc leaves the bare `argon2id/dist/*.wasm` specifiers untouched — bundles them from
38
+ * `node_modules` as `CompiledWasm`. `index.spec.ts` asserts `dist` still carries them that way.
39
+ */
40
+ import noSimdWasm from "argon2id/dist/no-simd.wasm";
41
+ import simdWasm from "argon2id/dist/simd.wasm";
42
+ import setupWasm, {} from "argon2id/lib/setup.js";
43
+ /**
44
+ * Turn one `.wasm` import into an instance. 🔴 A `Module` is instantiated and NEVER compiled —
45
+ * that is the whole workerd rule — and only a path (Bun) is read and compiled.
46
+ */
47
+ export async function instantiateWasm(source, imports) {
48
+ const module = typeof source === "string" ? await WebAssembly.compile(await Bun.file(source).arrayBuffer()) : source;
49
+ const instance = await WebAssembly.instantiate(module, imports);
50
+ return { module, instance };
51
+ }
52
+ /** Build a hasher from the two binaries — SIMD first, the plain one if SIMD will not load. */
53
+ export function loadArgon2(simd = simdWasm, noSimd = noSimdWasm) {
54
+ return setupWasm((imports) => instantiateWasm(simd, imports), (imports) => instantiateWasm(noSimd, imports));
55
+ }
56
+ /*
57
+ * 🔴 ONE hasher per isolate, built on first use. Not request state — it holds no password and no
58
+ * result between calls (the library clears its memory after each) — so this is the one kind of
59
+ * module-scope value a Worker may keep. The reason to keep it is MEMORY: each
60
+ * instance owns a 65 MB `WebAssembly.Memory` that can never shrink, and an unlock runs three or
61
+ * four derivations back to back; one per call would stack up to four of them against a 128 MB
62
+ * isolate before a collector ran. A load that FAILS is not cached, so the next unlock retries.
63
+ */
64
+ let shared = null;
65
+ export function argon2() {
66
+ shared ??= loadArgon2().catch((error) => {
67
+ shared = null;
68
+ throw error;
69
+ });
70
+ return shared;
71
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `cursedbelt-server/worker-argon2` — argon2id a Cloudflare Worker can afford, and the
3
+ * `Bun.password` shim that lets the same auth code verify on the Mac and on the edge.
4
+ *
5
+ * `./argon2.ts` is the WHY (8.5 s of billed CPU per unlock in pure JS; workerd forbids compiling
6
+ * WebAssembly, so the `.wasm` is a static import wrangler bundles); `./password.ts` is the shim.
7
+ */
8
+ export { argon2, instantiateWasm, loadArgon2, type WasmImport } from "./argon2.js";
9
+ export { type BunPasswordShim, installBunPasswordShim, parsePhc, workerPassword } from "./password.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `cursedbelt-server/worker-argon2` — argon2id a Cloudflare Worker can afford, and the
3
+ * `Bun.password` shim that lets the same auth code verify on the Mac and on the edge.
4
+ *
5
+ * `./argon2.ts` is the WHY (8.5 s of billed CPU per unlock in pure JS; workerd forbids compiling
6
+ * WebAssembly, so the `.wasm` is a static import wrangler bundles); `./password.ts` is the shim.
7
+ */
8
+ export { argon2, instantiateWasm, loadArgon2 } from "./argon2.js";
9
+ export { installBunPasswordShim, parsePhc, workerPassword } from "./password.js";
@@ -0,0 +1,26 @@
1
+ interface Phc {
2
+ m: number;
3
+ t: number;
4
+ p: number;
5
+ salt: Uint8Array;
6
+ hash: Uint8Array;
7
+ }
8
+ /** `$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>` → its parts, or null. */
9
+ export declare function parsePhc(encoded: string): Phc | null;
10
+ export interface BunPasswordShim {
11
+ verify(password: string, hash: string): Promise<boolean>;
12
+ hash(password: string, options?: {
13
+ algorithm?: string;
14
+ memoryCost?: number;
15
+ timeCost?: number;
16
+ }): Promise<string>;
17
+ }
18
+ export declare const workerPassword: BunPasswordShim;
19
+ /**
20
+ * Install the shim, once, before any route can run.
21
+ *
22
+ * Idempotent, and it never overwrites a real `Bun` — so importing this module in a
23
+ * Bun test does nothing, which is what keeps the gate honest about the Mac.
24
+ */
25
+ export declare function installBunPasswordShim(): void;
26
+ export {};
@@ -0,0 +1,164 @@
1
+ /**
2
+ * argon2id on `workerd`, where the obvious answer does not work.
3
+ *
4
+ * ## 🔴 The finding, and it applies to every app in this fleet
5
+ *
6
+ * **A Worker may not compile WebAssembly at runtime.** `hash-wasm` — the first and
7
+ * most natural choice, and the one every search result recommends — inlines its
8
+ * argon2 module as base64 and calls `WebAssembly.compile(bytes)` on first use.
9
+ * Deployed here on 2026-09-18, that produced:
10
+ *
11
+ * ```
12
+ * [patterns] argon2 verification threw: ← the pilot's log line, quoted verbatim
13
+ * WebAssembly.compile(): Wasm code generation disallowed by embedder
14
+ * ```
15
+ *
16
+ * It **builds, deploys and starts perfectly**, and fails only when somebody tries
17
+ * to sign in — where it is indistinguishable from a wrong password, because a KDF
18
+ * that throws must fail closed. That combination is the worst shape a defect can
19
+ * have, and it is why `verify` below logs the error: a silent `false` cost a
20
+ * diagnosis on the day this was written. On Workers, WASM must be a *static
21
+ * import* of a `.wasm` file that wrangler bundles as a module; a library that
22
+ * carries its bytes inline can never satisfy that.
23
+ *
24
+ * So this runs **argon2id as WebAssembly that wrangler bundles as a static module** —
25
+ * `./argon2.ts`, which instantiates a `WebAssembly.Module` it was handed and never compiles
26
+ * one. Until 2026-09-23 it was `@noble/hashes/argon2`, pure JavaScript: byte-identical output
27
+ * and 562 ms per verification on this Mac, which read as acceptable — and on workerd it was
28
+ * **~2.8 s per derivation**, so one unlock (every account's hash plus the timing equalizer)
29
+ * billed 8.5 s on the stage and 15 s in production, and was 489 CPU-ms averaged over every
30
+ * request the app served on 2026-09-22. `./argon2.ts` carries the measurement.
31
+ * `cursedbelt-server/login-throttle` is what holds the line against a flood, not the KDF's cost.
32
+ *
33
+ * ## What this buys, and it is the reason to pay for argon2id at all
34
+ *
35
+ * **The owner's existing password hash works on a Worker unchanged.** No re-hash,
36
+ * no "sign in again on the new host", and above all no downgrade to PBKDF2 to fit
37
+ * the runtime — `ownerAuth.ts` argues at length for argon2id over a fast KDF, and
38
+ * a port that quietly swapped it would have thrown that argument away for a
39
+ * platform detail. Same PHC string, same parameters, same answer.
40
+ *
41
+ * ## Why a GLOBAL shim rather than an injected verifier
42
+ *
43
+ * `Bun.password` is reached from three files (`auth.ts`, `ownerAuth.ts`,
44
+ * `cursedbelt-server/password`), and `Bun.` is a *global property access*, not an import — so
45
+ * a Worker bundle containing it builds fine and throws `Bun is not defined` only
46
+ * when a login happens. Threading a verifier through three signatures would be
47
+ * three more places for the Mac and the edge to disagree. Installing the global
48
+ * instead keeps the auth tier byte-identical on both runtimes, which is the same
49
+ * argument the apps' `store.ts` make for the async database seam.
50
+ *
51
+ * Used by `collections`, `vault` and `patterns` (each app's `worker/index.ts` calls
52
+ * {@link installBunPasswordShim} first thing). In `vault` this is ONLY the sign-in / lock
53
+ * verifier: the vault's ciphertext is derived and opened in the browser and never comes near it.
54
+ */
55
+ import { constantTimeEqualBytes } from "cwip/constant-time";
56
+ import { argon2 } from "./argon2.js";
57
+ /** What Bun's `argon2id` default writes, so a hash minted here matches one minted there. */
58
+ const DEFAULT_MEMORY_KIB = 65_536;
59
+ const DEFAULT_ITERATIONS = 2;
60
+ const DEFAULT_PARALLELISM = 1;
61
+ const HASH_BYTES = 32;
62
+ // 32, matching what `Bun.password.hash` actually writes — measured 2026-09-18.
63
+ const SALT_BYTES = 32;
64
+ /**
65
+ * argon2's PHC encoding is base64 **without padding**, and with the standard
66
+ * alphabet rather than base64url. Getting either wrong yields a salt that decodes
67
+ * to the wrong bytes and a verification that fails for a correct password.
68
+ */
69
+ const b64decode = (value) => {
70
+ const padded = value + "===".slice((value.length + 3) % 4);
71
+ return Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
72
+ };
73
+ const b64encode = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/=+$/, "");
74
+ /** `$argon2id$v=19$m=65536,t=2,p=1$<salt>$<hash>` → its parts, or null. */
75
+ export function parsePhc(encoded) {
76
+ const parts = encoded.split("$");
77
+ // ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
78
+ if (parts.length !== 6 || parts[1] !== "argon2id")
79
+ return null;
80
+ const params = new Map(parts[3].split(",").map((pair) => {
81
+ const [key, value] = pair.split("=");
82
+ return [key, Number(value)];
83
+ }));
84
+ const m = params.get("m");
85
+ const t = params.get("t");
86
+ const p = params.get("p");
87
+ if (!m || !t || !p)
88
+ return null;
89
+ try {
90
+ return { m, t, p, salt: b64decode(parts[4]), hash: b64decode(parts[5]) };
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ export const workerPassword = {
97
+ async verify(password, encoded) {
98
+ if (!password || !encoded)
99
+ return false;
100
+ try {
101
+ const phc = parsePhc(encoded);
102
+ if (!phc)
103
+ return false;
104
+ const derive = await argon2();
105
+ const derived = derive({
106
+ password: new TextEncoder().encode(password),
107
+ salt: phc.salt,
108
+ // 🔴 The STORED parameters, never this file's defaults. A hash written
109
+ // under a different cost must still verify, or rotating the cost locks
110
+ // the owner out of their own app.
111
+ passes: phc.t,
112
+ memorySize: phc.m,
113
+ parallelism: phc.p,
114
+ tagLength: phc.hash.length,
115
+ });
116
+ // Through the fleet's one constant-time primitive, exactly as
117
+ // `sync/tokens.ts` compares digests. A `===` here is a timing oracle on the
118
+ // one comparison in the app where it would matter.
119
+ return constantTimeEqualBytes(derived, phc.hash);
120
+ }
121
+ catch (error) {
122
+ // A malformed stored hash must fail CLOSED, exactly as `auth.ts` does on
123
+ // Bun. Returning `false` rather than throwing keeps the refusal a phrase
124
+ // instead of a 500 that tells a caller the hash is broken.
125
+ //
126
+ // 🔴 But it is LOGGED, because a silent `false` here is indistinguishable
127
+ // from a wrong password — and on 2026-09-18 that cost a diagnosis: a
128
+ // working credential was refused and the only line anywhere said "invalid
129
+ // email or password". Never the password, never the hash; the error only.
130
+ console.error(`[cursedbelt-server/worker-argon2] argon2 verification threw: ${error instanceof Error ? error.message : String(error)}`);
131
+ return false;
132
+ }
133
+ },
134
+ async hash(password, options = {}) {
135
+ const salt = new Uint8Array(SALT_BYTES);
136
+ crypto.getRandomValues(salt);
137
+ // `memoryCost`/`timeCost` are Bun's names; `cursedbelt-server/password` passes exactly
138
+ // those two, and its test profile passes much smaller ones.
139
+ const m = options.memoryCost ?? DEFAULT_MEMORY_KIB;
140
+ const t = options.timeCost ?? DEFAULT_ITERATIONS;
141
+ const derive = await argon2();
142
+ const derived = derive({
143
+ password: new TextEncoder().encode(password),
144
+ salt,
145
+ passes: t,
146
+ memorySize: m,
147
+ parallelism: DEFAULT_PARALLELISM,
148
+ tagLength: HASH_BYTES,
149
+ });
150
+ return `$argon2id$v=19$m=${m},t=${t},p=${DEFAULT_PARALLELISM}$${b64encode(salt)}$${b64encode(derived)}`;
151
+ },
152
+ };
153
+ /**
154
+ * Install the shim, once, before any route can run.
155
+ *
156
+ * Idempotent, and it never overwrites a real `Bun` — so importing this module in a
157
+ * Bun test does nothing, which is what keeps the gate honest about the Mac.
158
+ */
159
+ export function installBunPasswordShim() {
160
+ const existing = globalThis.Bun;
161
+ if (existing)
162
+ return;
163
+ globalThis.Bun = { password: workerPassword };
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.22.0",
3
+ "version": "4.24.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -215,6 +215,12 @@
215
215
  "source": "./src/server/auth/passwordCost.ts",
216
216
  "import": "./dist/server/auth/passwordCost.js"
217
217
  },
218
+ "./worker-argon2": {
219
+ "types": "./dist/server/worker-argon2/index.d.ts",
220
+ "bun": "./src/server/worker-argon2/index.ts",
221
+ "source": "./src/server/worker-argon2/index.ts",
222
+ "import": "./dist/server/worker-argon2/index.js"
223
+ },
218
224
  "./request-log": {
219
225
  "types": "./dist/server/requestLog/requestMetrics.d.ts",
220
226
  "bun": "./src/server/requestLog/requestMetrics.ts",
@@ -301,6 +307,7 @@
301
307
  }
302
308
  },
303
309
  "dependencies": {
310
+ "argon2id": "1.0.1",
304
311
  "cursedbelt-core": "^2.1.1",
305
312
  "cursedops": "^0.6.0",
306
313
  "cwip": "^4.6.0",
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_ROUTE_CPU_BUDGET_MS,
5
5
  deriveCpuBudgetMs,
6
6
  MEASURED_FLEET_REQUESTS_PER_DAY,
7
+ MEASURED_WORKER_TRAFFIC,
7
8
  monthlyOverageUsd,
8
9
  OWNER_PROJECTED_REQUESTS_PER_DAY,
9
10
  resolveBudget,
@@ -64,6 +65,43 @@ describe('the derivation', () => {
64
65
  });
65
66
  });
66
67
 
68
+ describe('the WORKER re-derivation (2026-09-23, task 083)', () => {
69
+ const W = MEASURED_WORKER_TRAFFIC;
70
+ const perDay = W.requests / W.days;
71
+ const edgeGatePerMonth = (W.edgeGateCpuMs / W.days) * DAYS_PER_MONTH;
72
+
73
+ it('the naive call over the whole account says 9.1 — below the default — and that is recorded, not hidden', () => {
74
+ expect(perDay).toBeCloseTo(108_001, 0);
75
+ const naive = deriveCpuBudgetMs({ requestsPerDay: perDay });
76
+ expect(naive).toBeCloseTo(9.13, 2);
77
+ expect(naive).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS);
78
+ // …because 99.9 % of it is the edge gate, whose mean sits far under any route budget.
79
+ expect(W.edgeGateRequests / W.requests).toBeGreaterThan(0.998);
80
+ expect(W.edgeGateCpuMs / W.edgeGateRequests).toBeLessThan(DEFAULT_ROUTE_CPU_BUDGET_MS / 4);
81
+ });
82
+
83
+ it('🔴 the default stays under what an APP request may spend once the edge gate is set aside', () => {
84
+ const appAllowance = deriveCpuBudgetMs({
85
+ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY,
86
+ reservedCpuMsPerMonth: edgeGatePerMonth,
87
+ });
88
+ expect(appAllowance).toBeCloseTo(16.9, 1);
89
+ expect(DEFAULT_ROUTE_CPU_BUDGET_MS).toBeLessThanOrEqual(appAllowance);
90
+ });
91
+
92
+ it('and there is no evidence for tightening it: the account spends 30 % of its CPU allowance', () => {
93
+ const monthly = (W.cpuMs / W.days) * DAYS_PER_MONTH;
94
+ expect(monthly / WORKERS_PAID_INCLUDED_CPU_MS).toBeCloseTo(0.3, 2);
95
+ });
96
+
97
+ it('FAILURE PATH: a reservation that eats the allowance is refused, not turned into a negative budget', () => {
98
+ expect(() => deriveCpuBudgetMs({ reservedCpuMsPerMonth: WORKERS_PAID_INCLUDED_CPU_MS })).toThrow(
99
+ /reservedCpuMsPerMonth/,
100
+ );
101
+ expect(() => deriveCpuBudgetMs({ reservedCpuMsPerMonth: -1 })).toThrow(/reservedCpuMsPerMonth/);
102
+ });
103
+ });
104
+
67
105
  describe('what going over actually costs', () => {
68
106
  it('prices the overage — 3× the CPU budget at this traffic is about $1.20/month', () => {
69
107
  const requestsPerMonth = OWNER_PROJECTED_REQUESTS_PER_DAY * DAYS_PER_MONTH;
@@ -82,6 +82,48 @@ export const DAYS_PER_MONTH = 30.4375;
82
82
  */
83
83
  export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
84
84
 
85
+ /**
86
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
87
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
88
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
89
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
90
+ * (since 09-22) served from Workers:
91
+ *
92
+ * ```
93
+ * script requests billed CPU-ms mean
94
+ * binary-server-edge-gate 754,974 1,805,750 2.39
95
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
96
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
97
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
98
+ * 3 others 196 126
99
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
100
+ * ```
101
+ *
102
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
103
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
104
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
105
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
106
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
107
+ * those requests never use. The question the default answers is what an APP request may
108
+ * spend once the measured non-app spend is set aside:
109
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
110
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
111
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
112
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
113
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
114
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
115
+ */
116
+ export const MEASURED_WORKER_TRAFFIC = {
117
+ from: '2026-09-16T00:00:00Z',
118
+ until: '2026-09-23T00:00:00Z',
119
+ days: 7,
120
+ requests: 756_005,
121
+ cpuMs: 2_085_903,
122
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
123
+ edgeGateRequests: 754_974,
124
+ edgeGateCpuMs: 1_805_750,
125
+ } as const;
126
+
85
127
  /**
86
128
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
87
129
  * included CPU allowance.
@@ -99,9 +141,21 @@ export function deriveCpuBudgetMs(
99
141
  requestsPerMonth?: number;
100
142
  /** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
101
143
  requestsPerDay?: number;
144
+ /**
145
+ * CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
146
+ * the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
147
+ * Default: 0.
148
+ */
149
+ reservedCpuMsPerMonth?: number;
102
150
  } = {},
103
151
  ): number {
104
- const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
152
+ const reserved = opts.reservedCpuMsPerMonth ?? 0;
153
+ const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
154
+ if (!(reserved >= 0) || !(includedCpuMs > 0)) {
155
+ throw new Error(
156
+ `deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`,
157
+ );
158
+ }
105
159
  const requestsPerMonth =
106
160
  opts.requestsPerMonth ??
107
161
  (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
@@ -136,6 +190,9 @@ export function monthlyOverageUsd(opts: {
136
190
  *
137
191
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
138
192
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
193
+ *
194
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
195
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
139
196
  */
140
197
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
141
198
 
@@ -23,11 +23,23 @@ import { BENCH_ORIGIN, runCpuBench } from './runBench.js';
23
23
  * timing would make the suite flaky for no extra truth.
24
24
  */
25
25
 
26
- /** Burn approximately `ms` of CPU. Deliberately un-optimizable: the result escapes. */
26
+ /**
27
+ * Burn at least `ms` of CPU. Deliberately un-optimizable: the result escapes.
28
+ *
29
+ * 🔴 The deadline is on the CPU clock, not the wall clock. The recorder measures
30
+ * `process.cpuUsage`; a burner that stops at a WALL deadline hands it only the share of
31
+ * those milliseconds the scheduler granted, so under load (measured 2026-09-23, load 17 on
32
+ * 14 cores) a 12 ms burn read 5.5 CPU-ms and reddened `row.max > 6`. Spinning until the
33
+ * process has actually been charged `ms` makes every lower bound here hold at any load.
34
+ */
27
35
  function burnCpu(ms: number): number {
28
- const deadline = performance.now() + ms;
36
+ const start = process.cpuUsage();
37
+ const spent = () => {
38
+ const d = process.cpuUsage(start);
39
+ return (d.user + d.system) / 1000;
40
+ };
29
41
  let acc = 0;
30
- while (performance.now() < deadline) {
42
+ while (spent() < ms) {
31
43
  for (let i = 0; i < 2_000; i += 1) acc += Math.sqrt(i + acc % 7);
32
44
  }
33
45
  return acc;
@@ -50,6 +50,7 @@ export {
50
50
  deriveCpuBudgetMs,
51
51
  isExemption,
52
52
  MEASURED_FLEET_REQUESTS_PER_DAY,
53
+ MEASURED_WORKER_TRAFFIC,
53
54
  monthlyOverageUsd,
54
55
  OWNER_PROJECTED_REQUESTS_PER_DAY,
55
56
  type ResolvedBudget,
@@ -20,8 +20,19 @@ export interface MetricRow {
20
20
  durationMs: number | null;
21
21
  bytesOut: number | null;
22
22
  userId: string | null;
23
+ /**
24
+ * Where the request came from — a PLACE, never an address (4.23.0). Filled by
25
+ * `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
26
+ * deliberately no `ip` field: the raw address is never stored, anywhere.
27
+ */
28
+ country?: string | null;
29
+ region?: string | null;
30
+ city?: string | null;
23
31
  }
24
32
 
33
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
34
+ export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
35
+
25
36
  export interface MetricsBufferOptions {
26
37
  db: Database;
27
38
  /** Flush cadence in ms. Default: 1500. */
@@ -45,9 +56,17 @@ export function createMetricsBuffer(opts: MetricsBufferOptions): MetricsBuffer {
45
56
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
46
57
  let buffer: MetricRow[] = [];
47
58
 
59
+ // 🔴 The place columns are written only where the table has them (4.23.0). An app that
60
+ // created `request_metrics` with its own DDL before they existed keeps working unchanged —
61
+ // a fixed column list would make `prepare` throw on its first boot after the bump.
62
+ const present = new Set(
63
+ (db.query('PRAGMA table_info(request_metrics)').all() as Array<{ name: string }>).map((c) => c.name),
64
+ );
65
+ const place = PLACE_COLUMNS.filter((c) => present.has(c));
66
+ const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
48
67
  const insert = db.prepare(
49
- `INSERT INTO request_metrics (id, ts, method, route, status, duration_ms, bytes_out, user_id)
50
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
68
+ `INSERT INTO request_metrics (${columns.join(', ')})
69
+ VALUES (${columns.map(() => '?').join(', ')})`,
51
70
  );
52
71
  const insertMany = db.transaction((rows: MetricRow[]) => {
53
72
  for (const r of rows) {
@@ -60,6 +79,7 @@ export function createMetricsBuffer(opts: MetricsBufferOptions): MetricsBuffer {
60
79
  r.durationMs,
61
80
  r.bytesOut,
62
81
  r.userId,
82
+ ...place.map((c) => r[c] ?? null),
63
83
  );
64
84
  }
65
85
  });