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.
- package/README.md +8 -0
- package/dist/server/bench/budget.d.ts +50 -0
- package/dist/server/bench/budget.js +49 -1
- package/dist/server/bench/index.d.ts +1 -1
- package/dist/server/bench/index.js +1 -1
- package/dist/server/metrics/metricsBuffer.d.ts +10 -0
- package/dist/server/metrics/metricsBuffer.js +11 -3
- package/dist/server/metrics/requestPlace.d.ts +48 -0
- package/dist/server/metrics/requestPlace.js +77 -0
- package/dist/server/metrics/telemetrySink.d.ts +6 -1
- package/dist/server/metrics/telemetrySink.js +14 -2
- package/dist/server/middleware/requestLogger.d.ts +8 -0
- package/dist/server/middleware/requestLogger.js +4 -1
- package/dist/server/requestLog/requestMetrics.d.ts +11 -2
- package/dist/server/requestLog/requestMetrics.js +22 -4
- package/dist/server/telemetry.d.ts +1 -0
- package/dist/server/telemetry.js +1 -0
- package/dist/server/worker-argon2/argon2.d.ts +11 -0
- package/dist/server/worker-argon2/argon2.js +71 -0
- package/dist/server/worker-argon2/index.d.ts +9 -0
- package/dist/server/worker-argon2/index.js +9 -0
- package/dist/server/worker-argon2/password.d.ts +26 -0
- package/dist/server/worker-argon2/password.js +164 -0
- package/package.json +8 -1
- package/src/server/bench/budget.spec.ts +38 -0
- package/src/server/bench/budget.ts +58 -1
- package/src/server/bench/cpuBudget.spec.ts +15 -3
- package/src/server/bench/index.ts +1 -0
- package/src/server/metrics/metricsBuffer.ts +22 -2
- package/src/server/metrics/requestPlace.spec.ts +142 -0
- package/src/server/metrics/requestPlace.ts +92 -0
- package/src/server/metrics/telemetrySink.spec.ts +2 -1
- package/src/server/metrics/telemetrySink.ts +14 -2
- package/src/server/middleware/requestLogger.ts +12 -1
- package/src/server/requestLog/requestMetrics.spec.ts +73 -0
- package/src/server/requestLog/requestMetrics.ts +37 -4
- package/src/server/telemetry.ts +5 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +17 -0
- package/src/server/worker-argon2/argon2.ts +85 -0
- package/src/server/worker-argon2/index.spec.ts +162 -0
- package/src/server/worker-argon2/index.ts +9 -0
- package/src/server/worker-argon2/password.ts +185 -0
- 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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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 (
|
|
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;
|
|
@@ -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 (
|
|
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
|
});
|