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
package/README.md CHANGED
@@ -49,6 +49,14 @@ about the whole options object rather than the one field. Use `serveBun(app, { w
49
49
  which takes it as a genuinely optional field and makes the two concrete calls itself.
50
50
  `src/server/serveBunOverload.spec.ts` runs `tsc` on the trap and goes red when bun's types change.
51
51
 
52
+ ## 🔴 argon2id on a Worker: `cursedbelt-server/worker-argon2`
53
+
54
+ A Worker may not COMPILE WebAssembly, and pure-JS argon2id costs ~2.8 s of billed CPU per
55
+ derivation on workerd (collections' unlock was 8.5 s). `installBunPasswordShim()` first thing in
56
+ the Worker's `fetch` gives `Bun.password` to the auth code, backed by `argon2id`'s two `.wasm`
57
+ files as static imports that wrangler bundles from `node_modules`. No wrangler rule and no local
58
+ `.wasm` declaration are needed. `src/server/worker-argon2/argon2.ts` has the measurements.
59
+
52
60
  ## Standards
53
61
 
54
62
  `docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` — the
@@ -74,6 +74,47 @@ export declare const DAYS_PER_MONTH = 30.4375;
74
74
  * the default may never drift ABOVE the measured allowance without a red build.
75
75
  */
76
76
  export declare const MEASURED_FLEET_REQUESTS_PER_DAY = 42971;
77
+ /**
78
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
79
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
80
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
81
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
82
+ * (since 09-22) served from Workers:
83
+ *
84
+ * ```
85
+ * script requests billed CPU-ms mean
86
+ * binary-server-edge-gate 754,974 1,805,750 2.39
87
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
88
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
89
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
90
+ * 3 others 196 126
91
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
92
+ * ```
93
+ *
94
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
95
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
96
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
97
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
98
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
99
+ * those requests never use. The question the default answers is what an APP request may
100
+ * spend once the measured non-app spend is set aside:
101
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
102
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
103
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
104
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
105
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
106
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
107
+ */
108
+ export declare const MEASURED_WORKER_TRAFFIC: {
109
+ readonly from: "2026-09-16T00:00:00Z";
110
+ readonly until: "2026-09-23T00:00:00Z";
111
+ readonly days: 7;
112
+ readonly requests: 756005;
113
+ readonly cpuMs: 2085903;
114
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
115
+ readonly edgeGateRequests: 754974;
116
+ readonly edgeGateCpuMs: 1805750;
117
+ };
77
118
  /**
78
119
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
79
120
  * included CPU allowance.
@@ -90,6 +131,12 @@ export declare function deriveCpuBudgetMs(opts?: {
90
131
  requestsPerMonth?: number;
91
132
  /** Measured (or projected) requests per day — converted with {@link DAYS_PER_MONTH}. */
92
133
  requestsPerDay?: number;
134
+ /**
135
+ * CPU-ms per month already spent by traffic OUTSIDE the budgeted routes (measured —
136
+ * the edge gate in {@link MEASURED_WORKER_TRAFFIC}), taken off the allowance first.
137
+ * Default: 0.
138
+ */
139
+ reservedCpuMsPerMonth?: number;
93
140
  }): number;
94
141
  /**
95
142
  * What a month costs in overage once the included allowances are spent. Used by the
@@ -112,6 +159,9 @@ export declare function monthlyOverageUsd(opts: {
112
159
  *
113
160
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
114
161
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
162
+ *
163
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
164
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
115
165
  */
116
166
  export declare const DEFAULT_ROUTE_CPU_BUDGET_MS: number;
117
167
  /** A route that is measured against a number. */
@@ -74,6 +74,47 @@ export const DAYS_PER_MONTH = 30.4375;
74
74
  * the default may never drift ABOVE the measured allowance without a red build.
75
75
  */
76
76
  export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
77
+ /**
78
+ * 🔴 The WORKER re-derivation — 2026-09-23 (task 083, bullet 3). Cloudflare's own numbers,
79
+ * not a proxy: the account's `workersInvocationsAdaptive` dataset (GraphQL Analytics API,
80
+ * `sum { requests cpuTimeUs }` by `scriptName`) over the seven whole days
81
+ * 2026-09-16T00:00Z → 2026-09-23T00:00Z, while `patterns` (since 09-18) and `collections`
82
+ * (since 09-22) served from Workers:
83
+ *
84
+ * ```
85
+ * script requests billed CPU-ms mean
86
+ * binary-server-edge-gate 754,974 1,805,750 2.39
87
+ * collections 350 171,228 489.2 (09-22: p99 15.2 s — one-off imports)
88
+ * collections-stage 61 96,081 1575 (stage: seeding and gate runs)
89
+ * patterns 424 12,718 30.0 (09-18: p99 2.9 s, first boot)
90
+ * 3 others 196 126
91
+ * ACCOUNT 756,005 2,085,903 2.76 → 108,001 req/day, 9.07 M CPU-ms/month (30 %)
92
+ * ```
93
+ *
94
+ * **What the call says.** `deriveCpuBudgetMs({ requestsPerDay: 108_001 })` = **9.1**, i.e.
95
+ * BELOW the shipped 9.9 — the account already carries more requests than the owner's
96
+ * 100,000/day projection. **The default did not move, and this is why:** 99.9 % of those
97
+ * requests are the binary server's edge gate, a fixed-cost auth check at 2.39 CPU-ms mean
98
+ * that no app route budget governs. Averaging them in hands the app routes an allowance
99
+ * those requests never use. The question the default answers is what an APP request may
100
+ * spend once the measured non-app spend is set aside:
101
+ * `deriveCpuBudgetMs({ requestsPerDay: MEASURED_FLEET_REQUESTS_PER_DAY, reservedCpuMsPerMonth: edge gate })`
102
+ * = **16.9** CPU-ms — the fleet's whole app traffic (42,971/day, above) on Workers, with the
103
+ * edge gate still running beside it. 9.9 stays under that with 1.7× headroom, and the
104
+ * account is at 30 % of its CPU allowance, so there is no evidence for tightening it either.
105
+ * App-Worker traffic itself (~111/day, mostly agent deploy checks) is too small to derive
106
+ * anything from yet. `budget.spec.ts` holds both orderings as assertions.
107
+ */
108
+ export const MEASURED_WORKER_TRAFFIC = {
109
+ from: '2026-09-16T00:00:00Z',
110
+ until: '2026-09-23T00:00:00Z',
111
+ days: 7,
112
+ requests: 756_005,
113
+ cpuMs: 2_085_903,
114
+ /** The binary server's edge gate — fixed-cost traffic no app route budget governs. */
115
+ edgeGateRequests: 754_974,
116
+ edgeGateCpuMs: 1_805_750,
117
+ };
77
118
  /**
78
119
  * Derive the average CPU-ms a single request may spend before the fleet exceeds its
79
120
  * included CPU allowance.
@@ -84,7 +125,11 @@ export const MEASURED_FLEET_REQUESTS_PER_DAY = 42_971;
84
125
  * allowed to become the common case.
85
126
  */
86
127
  export function deriveCpuBudgetMs(opts = {}) {
87
- const includedCpuMs = opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS;
128
+ const reserved = opts.reservedCpuMsPerMonth ?? 0;
129
+ const includedCpuMs = (opts.includedCpuMs ?? WORKERS_PAID_INCLUDED_CPU_MS) - reserved;
130
+ if (!(reserved >= 0) || !(includedCpuMs > 0)) {
131
+ throw new Error(`deriveCpuBudgetMs: reservedCpuMsPerMonth (${reserved}) must be ≥ 0 and leave some allowance`);
132
+ }
88
133
  const requestsPerMonth = opts.requestsPerMonth ??
89
134
  (opts.requestsPerDay ?? OWNER_PROJECTED_REQUESTS_PER_DAY) * DAYS_PER_MONTH;
90
135
  if (!(requestsPerMonth > 0)) {
@@ -113,6 +158,9 @@ export function monthlyOverageUsd(opts) {
113
158
  *
114
159
  * 🔴 Deliberately kept BELOW the ~22.9 ms that {@link MEASURED_FLEET_REQUESTS_PER_DAY}
115
160
  * derives; that constant says why, and `budget.spec.ts` reddens if the two ever swap.
161
+ *
162
+ * Re-derived from WORKER traffic on 2026-09-23 and kept: {@link MEASURED_WORKER_TRAFFIC}
163
+ * has the numbers (16.9 ms per app request after the edge gate's measured spend).
116
164
  */
117
165
  export const DEFAULT_ROUTE_CPU_BUDGET_MS = Math.round(deriveCpuBudgetMs() * 10) / 10;
118
166
  export function isExemption(b) {
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { type AssertCpuBudgetsOpts, assertCpuBudgets, checkCpuBudgets, type CpuViolation, type CpuViolationKind, formatCpuBudgetReport, } from './assert.js';
36
- export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
36
+ export { type CpuBudgetConfig, type CpuBudgetExemption, type CpuBudgetLimit, DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, type ResolvedBudget, resolveBudget, type RouteBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
37
37
  export { type CpuBudgetOpts, cpuBudget } from './cpuBudget.js';
38
38
  export { type CpuClock, type CpuSpan, fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { type CpuBudgetReport, type CpuRecorder, type CpuRecorderOpts, createCpuRecorder, percentile, type RouteCpuStats, } from './recorder.js';
@@ -33,7 +33,7 @@
33
33
  * alternative is a number that gets quoted as a billing fact.
34
34
  */
35
35
  export { assertCpuBudgets, checkCpuBudgets, formatCpuBudgetReport, } from './assert.js';
36
- export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
36
+ export { DAYS_PER_MONTH, DEFAULT_ROUTE_CPU_BUDGET_MS, deriveCpuBudgetMs, isExemption, MEASURED_FLEET_REQUESTS_PER_DAY, MEASURED_WORKER_TRAFFIC, monthlyOverageUsd, OWNER_PROJECTED_REQUESTS_PER_DAY, resolveBudget, validateCpuBudgetConfig, WORKERS_PAID_INCLUDED_CPU_MS, WORKERS_PAID_INCLUDED_REQUESTS, WORKERS_PAID_USD_PER_MILLION_CPU_MS, WORKERS_PAID_USD_PER_MILLION_REQUESTS, } from './budget.js';
37
37
  export { cpuBudget } from './cpuBudget.js';
38
38
  export { fixedCpuClock, processCpuClock, resolveCpuClock, unavailableCpuClock, workerCpuClock, } from './cpuClock.js';
39
39
  export { createCpuRecorder, percentile, } from './recorder.js';
@@ -18,7 +18,17 @@ export interface MetricRow {
18
18
  durationMs: number | null;
19
19
  bytesOut: number | null;
20
20
  userId: string | null;
21
+ /**
22
+ * Where the request came from — a PLACE, never an address (4.23.0). Filled by
23
+ * `requestLogger`'s `locate` hook; absent/null when nothing could place it. There is
24
+ * deliberately no `ip` field: the raw address is never stored, anywhere.
25
+ */
26
+ country?: string | null;
27
+ region?: string | null;
28
+ city?: string | null;
21
29
  }
30
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
31
+ export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
22
32
  export interface MetricsBufferOptions {
23
33
  db: Database;
24
34
  /** Flush cadence in ms. Default: 1500. */
@@ -1,11 +1,19 @@
1
+ /** The optional place columns, in insert order. Each is written only when the table HAS it. */
2
+ export const PLACE_COLUMNS = ['country', 'region', 'city'];
1
3
  export function createMetricsBuffer(opts) {
2
4
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
3
5
  let buffer = [];
4
- const insert = db.prepare(`INSERT INTO request_metrics (id, ts, method, route, status, duration_ms, bytes_out, user_id)
5
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
6
+ // 🔴 The place columns are written only where the table has them (4.23.0). An app that
7
+ // created `request_metrics` with its own DDL before they existed keeps working unchanged —
8
+ // a fixed column list would make `prepare` throw on its first boot after the bump.
9
+ const present = new Set(db.query('PRAGMA table_info(request_metrics)').all().map((c) => c.name));
10
+ const place = PLACE_COLUMNS.filter((c) => present.has(c));
11
+ const columns = ['id', 'ts', 'method', 'route', 'status', 'duration_ms', 'bytes_out', 'user_id', ...place];
12
+ const insert = db.prepare(`INSERT INTO request_metrics (${columns.join(', ')})
13
+ VALUES (${columns.map(() => '?').join(', ')})`);
6
14
  const insertMany = db.transaction((rows) => {
7
15
  for (const r of rows) {
8
- insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId);
16
+ insert.run(crypto.randomUUID(), r.ts, r.method, r.route, r.status, r.durationMs, r.bytesOut, r.userId, ...place.map((c) => r[c] ?? null));
9
17
  }
10
18
  });
11
19
  const flush = () => {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where a request came from — as a PLACE, never as an address (4.23.0, task 2123).
3
+ *
4
+ * ── 🔴 The raw IP is never stored ───────────────────────────────────────────
5
+ * The retired instances' logger wrote `ip` on every row. This one does not, anywhere: not in
6
+ * `request_metrics`, not in an Analytics Engine blob. A {@link RequestLocator} receives the
7
+ * `Request`, may read whatever address it needs from it (`cf-connecting-ip` behind the tunnel),
8
+ * and returns only `{ country, region?, city? }`. The address dies with the request. That is
9
+ * the owner's privacy choice, fixed in the type rather than in a comment: {@link RequestPlace}
10
+ * has no field an address could go in, and {@link normalizePlace} copies only those three.
11
+ *
12
+ * ── The default reads the edge ──────────────────────────────────────────────
13
+ * {@link locateFromCloudflare} is what `requestLogger` uses when an app passes no `locate`:
14
+ *
15
+ * - on a Worker, `request.cf.country/region/city` — Cloudflare's own geolocation;
16
+ * - behind the tunnel (a Mac app), the `cf-ipcountry` header every proxied request carries,
17
+ * plus `cf-region`/`cf-ipcity` when the zone's "visitor location headers" transform is on.
18
+ *
19
+ * A Bun process reached directly (dev, tests) has neither, and gets `null` — an unplaced row,
20
+ * never an invented one. An app that can do better (station's DB-IP snapshot) passes its own
21
+ * `locate`, and should merge per field with this one so the edge wins wherever it speaks.
22
+ *
23
+ * Worker-safe: no `bun:` import (`telemetryIsWorkerSafe.spec.ts` walks this graph).
24
+ */
25
+ /** A place. `country` is an ISO-3166 alpha-2 code as the edge spells it (`US`, `IE`, `T1` for Tor). */
26
+ export interface RequestPlace {
27
+ country: string;
28
+ region?: string | null;
29
+ city?: string | null;
30
+ }
31
+ /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
32
+ export type RequestLocator = (request: Request) => RequestPlace | null;
33
+ /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
34
+ export declare const PLACE_FIELD_MAX_CHARS = 96;
35
+ /**
36
+ * The three columns a row stores, from whatever a locator returned. Copies ONLY
37
+ * `country/region/city` — so a locator that returns extra fields (an `ip`, say) cannot get them
38
+ * written — and treats an empty or unknown country as no place at all.
39
+ */
40
+ export declare function normalizePlace(place: RequestPlace | null | undefined): {
41
+ country: string | null;
42
+ region: string | null;
43
+ city: string | null;
44
+ };
45
+ /** The edge's own answer: `request.cf` on a Worker, the `cf-*` headers behind the tunnel, else `null`. */
46
+ export declare const locateFromCloudflare: RequestLocator;
47
+ /** Run a locator the way the logger must: a throw is an unplaced request, never a failed one. */
48
+ export declare function placeOf(locate: RequestLocator, request: Request): ReturnType<typeof normalizePlace>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Where a request came from — as a PLACE, never as an address (4.23.0, task 2123).
3
+ *
4
+ * ── 🔴 The raw IP is never stored ───────────────────────────────────────────
5
+ * The retired instances' logger wrote `ip` on every row. This one does not, anywhere: not in
6
+ * `request_metrics`, not in an Analytics Engine blob. A {@link RequestLocator} receives the
7
+ * `Request`, may read whatever address it needs from it (`cf-connecting-ip` behind the tunnel),
8
+ * and returns only `{ country, region?, city? }`. The address dies with the request. That is
9
+ * the owner's privacy choice, fixed in the type rather than in a comment: {@link RequestPlace}
10
+ * has no field an address could go in, and {@link normalizePlace} copies only those three.
11
+ *
12
+ * ── The default reads the edge ──────────────────────────────────────────────
13
+ * {@link locateFromCloudflare} is what `requestLogger` uses when an app passes no `locate`:
14
+ *
15
+ * - on a Worker, `request.cf.country/region/city` — Cloudflare's own geolocation;
16
+ * - behind the tunnel (a Mac app), the `cf-ipcountry` header every proxied request carries,
17
+ * plus `cf-region`/`cf-ipcity` when the zone's "visitor location headers" transform is on.
18
+ *
19
+ * A Bun process reached directly (dev, tests) has neither, and gets `null` — an unplaced row,
20
+ * never an invented one. An app that can do better (station's DB-IP snapshot) passes its own
21
+ * `locate`, and should merge per field with this one so the edge wins wherever it speaks.
22
+ *
23
+ * Worker-safe: no `bun:` import (`telemetryIsWorkerSafe.spec.ts` walks this graph).
24
+ */
25
+ /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
26
+ export const PLACE_FIELD_MAX_CHARS = 96;
27
+ /** Cloudflare's "no country could be determined" code — an unknown, not a place. */
28
+ const UNKNOWN_COUNTRY = 'XX';
29
+ const clean = (value) => {
30
+ if (typeof value !== 'string')
31
+ return null;
32
+ const trimmed = value.trim().slice(0, PLACE_FIELD_MAX_CHARS);
33
+ return trimmed === '' ? null : trimmed;
34
+ };
35
+ /**
36
+ * The three columns a row stores, from whatever a locator returned. Copies ONLY
37
+ * `country/region/city` — so a locator that returns extra fields (an `ip`, say) cannot get them
38
+ * written — and treats an empty or unknown country as no place at all.
39
+ */
40
+ export function normalizePlace(place) {
41
+ const country = clean(place?.country);
42
+ if (!country || country.toUpperCase() === UNKNOWN_COUNTRY)
43
+ return { country: null, region: null, city: null };
44
+ return { country, region: clean(place?.region), city: clean(place?.city) };
45
+ }
46
+ /** Header values arrive percent-encoded when the edge had to (`cf-ipcity: S%C3%A3o%20Paulo`). */
47
+ const header = (request, name) => {
48
+ const raw = request.headers.get(name);
49
+ if (raw == null)
50
+ return null;
51
+ try {
52
+ return decodeURIComponent(raw);
53
+ }
54
+ catch {
55
+ return raw;
56
+ }
57
+ };
58
+ /** The edge's own answer: `request.cf` on a Worker, the `cf-*` headers behind the tunnel, else `null`. */
59
+ export const locateFromCloudflare = (request) => {
60
+ const cf = request.cf;
61
+ if (cf && typeof cf.country === 'string') {
62
+ return { country: cf.country, region: clean(cf.region), city: clean(cf.city) };
63
+ }
64
+ const country = header(request, 'cf-ipcountry');
65
+ if (!country)
66
+ return null;
67
+ return { country, region: header(request, 'cf-region'), city: header(request, 'cf-ipcity') };
68
+ };
69
+ /** Run a locator the way the logger must: a throw is an unplaced request, never a failed one. */
70
+ export function placeOf(locate, request) {
71
+ try {
72
+ return normalizePlace(locate(request));
73
+ }
74
+ catch {
75
+ return normalizePlace(null);
76
+ }
77
+ }
@@ -91,8 +91,13 @@ export interface TelemetrySinkOptions {
91
91
  * index1 = route, truncated to 96 bytes — the sampling key, so sampling is per route
92
92
  * blob1 = method blob2 = route (untruncated) blob3 = userId ('' when anonymous)
93
93
  * double1 = status double2 = durationMs double3 = bytesOut
94
+ * blob4 = country blob5 = region blob6 = city (v2, 4.23.0)
94
95
  * ```
95
96
  *
97
+ * v2 APPENDED blob4–6: where the request came from, as a place and never an address
98
+ * (`./requestPlace.ts`). `''` when unplaced, like `userId`. A v1 query never reads past blob3,
99
+ * so it is unaffected; a query written against v2 reads `''` on points written before it.
100
+ *
96
101
  * `ts` is deliberately absent: Analytics Engine stamps its own `timestamp`
97
102
  * column, so sending ours would store the same instant twice.
98
103
  *
@@ -100,7 +105,7 @@ export interface TelemetrySinkOptions {
100
105
  * doubles array cannot hold a null and `0` is a real value for every one of them
101
106
  * (a 0-byte 204, notably). Read it as `NULL`, not as a measurement.
102
107
  */
103
- export declare const TELEMETRY_POINT_VERSION = 1;
108
+ export declare const TELEMETRY_POINT_VERSION = 2;
104
109
  /** The documented layout above, as data. Exported so the spec asserts the wire format itself. */
105
110
  export declare function toDataPoint(event: TelemetryEvent): {
106
111
  indexes: string[];
@@ -8,8 +8,13 @@ import { createMetricsBuffer } from './metricsBuffer.js';
8
8
  * index1 = route, truncated to 96 bytes — the sampling key, so sampling is per route
9
9
  * blob1 = method blob2 = route (untruncated) blob3 = userId ('' when anonymous)
10
10
  * double1 = status double2 = durationMs double3 = bytesOut
11
+ * blob4 = country blob5 = region blob6 = city (v2, 4.23.0)
11
12
  * ```
12
13
  *
14
+ * v2 APPENDED blob4–6: where the request came from, as a place and never an address
15
+ * (`./requestPlace.ts`). `''` when unplaced, like `userId`. A v1 query never reads past blob3,
16
+ * so it is unaffected; a query written against v2 reads `''` on points written before it.
17
+ *
13
18
  * `ts` is deliberately absent: Analytics Engine stamps its own `timestamp`
14
19
  * column, so sending ours would store the same instant twice.
15
20
  *
@@ -17,7 +22,7 @@ import { createMetricsBuffer } from './metricsBuffer.js';
17
22
  * doubles array cannot hold a null and `0` is a real value for every one of them
18
23
  * (a 0-byte 204, notably). Read it as `NULL`, not as a measurement.
19
24
  */
20
- export const TELEMETRY_POINT_VERSION = 1;
25
+ export const TELEMETRY_POINT_VERSION = 2;
21
26
  const INDEX_MAX_BYTES = 96;
22
27
  /**
23
28
  * Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point —
@@ -38,7 +43,14 @@ export function toDataPoint(event) {
38
43
  const route = event.route ?? '';
39
44
  return {
40
45
  indexes: [truncateUtf8(route, INDEX_MAX_BYTES)],
41
- blobs: [event.method ?? '', route, event.userId ?? ''],
46
+ blobs: [
47
+ event.method ?? '',
48
+ route,
49
+ event.userId ?? '',
50
+ event.country ?? '',
51
+ event.region ?? '',
52
+ event.city ?? '',
53
+ ],
42
54
  doubles: [num(event.status), num(event.durationMs), num(event.bytesOut)],
43
55
  };
44
56
  }
@@ -3,6 +3,7 @@ import type { MiddlewareHandler } from 'hono';
3
3
  import type { CursedbeltEnv } from '../context.js';
4
4
  import type { MetricsBuffer } from '../metrics/metricsBuffer.js';
5
5
  import { type AnalyticsEngineDataset, type TelemetrySink } from '../metrics/telemetrySink.js';
6
+ import { type RequestLocator } from '../metrics/requestPlace.js';
6
7
  /**
7
8
  * Structured request/response logging. After each request it records one
8
9
  * telemetry event through the {@link TelemetrySink} (off the hot path) and, for
@@ -33,6 +34,13 @@ export interface RequestLoggerOpts {
33
34
  sink?: TelemetrySink;
34
35
  /** @deprecated Use {@link RequestLoggerOpts.sink} — a `MetricsBuffer` is one. Kept for callers that predate the sink. */
35
36
  buffer?: MetricsBuffer;
37
+ /**
38
+ * Where the request came from — returns a PLACE (`{ country, region?, city? }`) or `null`,
39
+ * never an address; the raw IP is not stored anywhere (`../metrics/requestPlace.ts`).
40
+ * Default: {@link locateFromCloudflare} — `request.cf` on a Worker, the `cf-ipcountry`
41
+ * header behind the tunnel. A throw is caught and the request recorded unplaced.
42
+ */
43
+ locate?: RequestLocator;
36
44
  /** Duration (ms) at/above which a 2xx request also logs an `event_logs` row. Default: 2000. */
37
45
  slowMs?: number;
38
46
  /** Also write `event_logs` rows for status ≥ 400. Default: true. */
@@ -2,6 +2,7 @@ import { HTTPException } from 'hono/http-exception';
2
2
  import { isApiError } from '../errors.js';
3
3
  import { normalizeRoute } from '../metrics/normalizeRoute.js';
4
4
  import { createTelemetrySink, } from '../metrics/telemetrySink.js';
5
+ import { locateFromCloudflare, placeOf } from '../metrics/requestPlace.js';
5
6
  function statusOfThrown(err) {
6
7
  if (isApiError(err))
7
8
  return err.status;
@@ -20,6 +21,7 @@ export function requestLogger(db, opts = {}) {
20
21
  const sink = opts.sink ?? opts.buffer ?? createTelemetrySink({ db, analytics: opts.analytics ?? null });
21
22
  const slowMs = opts.slowMs ?? 2000;
22
23
  const logErrors = opts.logErrors ?? true;
24
+ const locate = opts.locate ?? locateFromCloudflare;
23
25
  const insertEvent = db
24
26
  ? db.prepare(`INSERT INTO event_logs
25
27
  (id, correlation_id, event_name, type, message, details, duration_ms, user_id, created_at)
@@ -42,7 +44,8 @@ export function requestLogger(db, opts = {}) {
42
44
  const lenHeader = didThrow ? null : c.res.headers.get('content-length');
43
45
  const bytesOut = lenHeader ? Number(lenHeader) : null;
44
46
  const userId = c.get('userId') ?? null;
45
- sink.record({ ts: Date.now(), method, route, status, durationMs, bytesOut, userId });
47
+ const place = placeOf(locate, c.req.raw);
48
+ sink.record({ ts: Date.now(), method, route, status, durationMs, bytesOut, userId, ...place });
46
49
  const isError = status >= 400;
47
50
  const isSlow = durationMs >= slowMs;
48
51
  if (insertEvent && ((logErrors && isError) || isSlow)) {
@@ -60,6 +60,7 @@
60
60
  */
61
61
  import { Database } from "bun:sqlite";
62
62
  import type { MiddlewareHandler } from "hono";
63
+ import type { RequestLocator } from "../metrics/requestPlace.js";
63
64
  import { type TelemetrySink } from "../metrics/telemetrySink.js";
64
65
  /** The file station reads, inside the app's data directory. */
65
66
  export declare const METRICS_DB = "metrics.sqlite";
@@ -80,12 +81,20 @@ export interface RequestMetrics {
80
81
  /** Where an inherited corpus went at this boot, if one was found. */
81
82
  archived: string | null;
82
83
  }
84
+ export interface RequestMetricsOptions {
85
+ /**
86
+ * Place each request (`{ country, region?, city? }` or `null`) — never an address; the raw IP
87
+ * is not stored. Default: the edge's own answer, `locateFromCloudflare` (the `cf-ipcountry`
88
+ * header every tunnelled request carries). station passes its DB-IP reader here.
89
+ */
90
+ locate?: RequestLocator;
91
+ }
83
92
  /** Open the corpus in `dir` and build the logger that writes into it. */
84
- export declare function openRequestMetrics(dir: string): RequestMetrics;
93
+ export declare function openRequestMetrics(dir: string, options?: RequestMetricsOptions): RequestMetrics;
85
94
  /**
86
95
  * The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
87
96
  * is set (the launchd plist sets it) and no test runner is in charge. A dev shell
88
97
  * records nothing rather than appending a developer's clicks to the live corpus.
89
98
  * Never throws: a failure is one stderr line and an app that serves unmeasured.
90
99
  */
91
- export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null;
100
+ export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv, options?: RequestMetricsOptions): RequestMetrics | null;
@@ -62,6 +62,7 @@ import { Database } from "bun:sqlite";
62
62
  import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
63
  import { join } from "node:path";
64
64
  import { applyCcPragmas } from "cwip/sqlite";
65
+ import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
65
66
  import { createTelemetrySink } from "../metrics/telemetrySink.js";
66
67
  import { requestLogger } from "../middleware/requestLogger.js";
67
68
  import { runMigrations } from "../migration/runner.js";
@@ -90,6 +91,20 @@ const REQUEST_METRICS_MIGRATIONS = [
90
91
  db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
91
92
  },
92
93
  },
94
+ {
95
+ // 4.23.0, task 2123: where a request came from — a PLACE, never the address (no `ip`
96
+ // column, ever; `../metrics/requestPlace.ts`). Nullable, so every row written before
97
+ // this reads as unplaced. Idempotent beyond the runner's once-only record: a column
98
+ // already present (hand-added, or a half-applied copy of this step) is left alone.
99
+ name: "0002_request_place",
100
+ up(db) {
101
+ const have = new Set(db.query("PRAGMA table_info(request_metrics)").all().map((c) => c.name));
102
+ for (const column of PLACE_COLUMNS) {
103
+ if (!have.has(column))
104
+ db.run(`ALTER TABLE request_metrics ADD COLUMN ${column} TEXT`);
105
+ }
106
+ },
107
+ },
93
108
  ];
94
109
  /** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
95
110
  function headerApplicationId(path) {
@@ -145,7 +160,7 @@ function openMetricsDb(dir) {
145
160
  return { db, archived };
146
161
  }
147
162
  /** Open the corpus in `dir` and build the logger that writes into it. */
148
- export function openRequestMetrics(dir) {
163
+ export function openRequestMetrics(dir, options = {}) {
149
164
  const { db, archived } = openMetricsDb(dir);
150
165
  const inner = createTelemetrySink({ db });
151
166
  // 🔴 No user id — see the header. Nulled here so no context value can reach the row.
@@ -158,7 +173,10 @@ export function openRequestMetrics(dir) {
158
173
  },
159
174
  };
160
175
  // `null` database: the logger writes through `sink` only, and no `event_logs`.
161
- const middleware = requestLogger(null, { sink });
176
+ const middleware = requestLogger(null, {
177
+ sink,
178
+ ...(options.locate ? { locate: options.locate } : {}),
179
+ });
162
180
  return { db, sink, middleware, archived };
163
181
  }
164
182
  /**
@@ -167,12 +185,12 @@ export function openRequestMetrics(dir) {
167
185
  * records nothing rather than appending a developer's clicks to the live corpus.
168
186
  * Never throws: a failure is one stderr line and an app that serves unmeasured.
169
187
  */
170
- export function openRequestMetricsFromEnv(name, env) {
188
+ export function openRequestMetricsFromEnv(name, env, options = {}) {
171
189
  const dir = env.APP_DATA_DIR?.trim();
172
190
  if (!dir || isTestRuntime(env))
173
191
  return null;
174
192
  try {
175
- const metrics = openRequestMetrics(dir);
193
+ const metrics = openRequestMetrics(dir, options);
176
194
  if (metrics.archived) {
177
195
  console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
178
196
  }
@@ -17,3 +17,4 @@
17
17
  */
18
18
  export { type RequestLoggerOpts, requestLogger } from './middleware/requestLogger.js';
19
19
  export { type AnalyticsEngineDataset, createTelemetrySink, TELEMETRY_POINT_VERSION, type TelemetryEvent, type TelemetrySink, type TelemetrySinkOptions, toDataPoint, } from './metrics/telemetrySink.js';
20
+ export { locateFromCloudflare, type RequestLocator, type RequestPlace, } from './metrics/requestPlace.js';
@@ -17,3 +17,4 @@
17
17
  */
18
18
  export { requestLogger } from './middleware/requestLogger.js';
19
19
  export { createTelemetrySink, TELEMETRY_POINT_VERSION, toDataPoint, } from './metrics/telemetrySink.js';
20
+ export { locateFromCloudflare, } from './metrics/requestPlace.js';
@@ -0,0 +1,11 @@
1
+ import { type computeHash } from "argon2id/lib/setup.js";
2
+ /** What a `.wasm` import is: a compiled module under wrangler, a file path under Bun. */
3
+ export type WasmImport = WebAssembly.Module | string;
4
+ /**
5
+ * Turn one `.wasm` import into an instance. 🔴 A `Module` is instantiated and NEVER compiled —
6
+ * that is the whole workerd rule — and only a path (Bun) is read and compiled.
7
+ */
8
+ export declare function instantiateWasm(source: WasmImport, imports: WebAssembly.Imports): Promise<WebAssembly.WebAssemblyInstantiatedSource>;
9
+ /** Build a hasher from the two binaries — SIMD first, the plain one if SIMD will not load. */
10
+ export declare function loadArgon2(simd?: WasmImport, noSimd?: WasmImport): Promise<computeHash>;
11
+ export declare function argon2(): Promise<computeHash>;