cursedbelt-server 4.18.1 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/engagement/api.d.ts +71 -0
  12. package/dist/server/engagement/api.js +84 -0
  13. package/dist/server/engagement/env.d.ts +18 -0
  14. package/dist/server/engagement/env.js +52 -0
  15. package/dist/server/engagement/index.d.ts +55 -0
  16. package/dist/server/engagement/index.js +55 -0
  17. package/dist/server/engagement/places.d.ts +22 -0
  18. package/dist/server/engagement/places.js +63 -0
  19. package/dist/server/engagement/policy.d.ts +168 -0
  20. package/dist/server/engagement/policy.js +202 -0
  21. package/dist/server/engagement/store.d.ts +92 -0
  22. package/dist/server/engagement/store.js +223 -0
  23. package/dist/server/engagement/summary.d.ts +102 -0
  24. package/dist/server/engagement/summary.js +127 -0
  25. package/dist/server/engagement/types.d.ts +42 -0
  26. package/dist/server/engagement/types.js +12 -0
  27. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  28. package/dist/server/maps-budget/mapsBudget.js +193 -0
  29. package/dist/server/satellite/config.d.ts +173 -0
  30. package/dist/server/satellite/config.js +259 -0
  31. package/dist/server/satellite/door.d.ts +112 -0
  32. package/dist/server/satellite/door.js +149 -0
  33. package/dist/server/storage/binaryStore.d.ts +18 -0
  34. package/dist/server/storage/binaryStore.js +32 -1
  35. package/dist/server/storage/derivatives.d.ts +253 -0
  36. package/dist/server/storage/derivatives.js +266 -0
  37. package/dist/server/storage/uploadSession.d.ts +75 -0
  38. package/dist/server/storage/uploadSession.js +74 -0
  39. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  40. package/docs/activity.md +43 -0
  41. package/docs/engagement.md +47 -0
  42. package/docs/notifications.md +43 -0
  43. package/docs/retention.md +81 -0
  44. package/docs/skipped-tests.md +19 -0
  45. package/package.json +46 -9
  46. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  48. package/src/server/activity/index.ts +2 -1
  49. package/src/server/auth/passwordCost.spec.ts +42 -0
  50. package/src/server/auth/passwordCost.ts +87 -0
  51. package/src/server/bench/index.ts +13 -0
  52. package/src/server/bench/tail.spec.ts +126 -0
  53. package/src/server/bench/tail.ts +237 -0
  54. package/src/server/d1/index.ts +9 -9
  55. package/src/server/engagement/api.ts +119 -0
  56. package/src/server/engagement/engagement.spec.ts +462 -0
  57. package/src/server/engagement/env.ts +73 -0
  58. package/src/server/engagement/index.ts +92 -0
  59. package/src/server/engagement/places.ts +76 -0
  60. package/src/server/engagement/policy.ts +250 -0
  61. package/src/server/engagement/store.ts +272 -0
  62. package/src/server/engagement/summary.ts +216 -0
  63. package/src/server/engagement/types.ts +61 -0
  64. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  65. package/src/server/maps-budget/mapsBudget.ts +304 -0
  66. package/src/server/satellite/config.ts +389 -0
  67. package/src/server/satellite/door.ts +169 -0
  68. package/src/server/satellite/satellite.spec.ts +161 -0
  69. package/src/server/storage/binaryStore.ts +31 -1
  70. package/src/server/storage/derivatives.spec.ts +125 -0
  71. package/src/server/storage/derivatives.ts +329 -0
  72. package/src/server/storage/uploadSession.spec.ts +132 -0
  73. package/src/server/storage/uploadSession.ts +114 -0
  74. package/dist/server/d1/kysely.d.ts +0 -56
  75. package/dist/server/d1/kysely.js +0 -138
  76. package/src/server/d1/kysely.spec.ts +0 -145
  77. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HOW EXPENSIVE ARGON2ID SHOULD BE IN THIS PROCESS — production's cost, unless a test
3
+ * runner is in charge.
4
+ *
5
+ * ## The measurement (2026-09-09)
6
+ *
7
+ * Argon2id is slow ON PURPOSE: one hash costs ~66 ms on this Mac, and that slowness IS
8
+ * the security property. It is also the wrong price to pay six hundred times in a unit
9
+ * test whose assertion is *"a wrong password is refused"* — that asserts the comparison,
10
+ * never the cost of the KDF.
11
+ *
12
+ * `apps/auth`'s own suite, measured both ways with nothing else changed:
13
+ *
14
+ * production cost 52.85 s
15
+ * test cost 7.32 s
16
+ *
17
+ * Eighty-six per cent of what was, that morning, the largest single workspace in
18
+ * `verify:scoped` — paid by every agent on every branch touching auth or any shared
19
+ * package, and again on every union gate.
20
+ *
21
+ * ## Why this is safe to key on the test runtime, stated plainly
22
+ *
23
+ * A weakened KDF is the most dangerous thing this module could do, so the argument has
24
+ * to be better than "the flag is only set in tests".
25
+ *
26
+ * `TEST_RUNTIME_ENV_KEYS` is the fleet's existing answer to *"is a test runner in
27
+ * charge"*, and `app-data` **already refuses to open the owner's real database** when it
28
+ * is true. A process carrying one of these markers therefore has no production data to
29
+ * write a weak hash into — it is the same claim, load-bearing in the same direction,
30
+ * decided in one place. If that guarantee ever weakens, it weakens for the database
31
+ * first, which is the louder failure.
32
+ *
33
+ * The ALGORITHM never changes. Only `memoryCost`/`timeCost` move, argon2 encodes its own
34
+ * parameters into the hash, and verification is therefore identical — so a stored hash
35
+ * made under either cost verifies under either cost.
36
+ *
37
+ * ## `cursedbelt-server/password`, since 4.19.0 (task 321)
38
+
39
+ It was `src/kit/passwordCost.ts` in `auth`, `collections`, `patterns` and `vault` — four copies of
40
+ a security-relevant cost decision, one of them (`vault`) already carrying a `verifySecret` the
41
+ others lacked. The test-runtime rule it keys on is `./satellite-door`'s `isTestRuntime`, the same
42
+ predicate that refuses a test the owner's real database, so the two can never disagree about
43
+ whether a runner is in charge. It is not in `cursedauth` because that package has no
44
+ dependencies and this decision must share its predicate, not copy it.
45
+
46
+ ## Why it lives in one place and not in each app
47
+ *
48
+ * Five call sites hashed at production cost when this was written — `apps/auth`,
49
+ * `apps/collections` (×2), `apps/patterns`, `apps/vault` — and a per-app copy of a
50
+ * security-relevant cost decision is how four of them end up agreeing and the fifth does
51
+ * not. One module, one test, one place to read.
52
+ */
53
+ import { isTestRuntime } from "../satellite/door.js";
54
+
55
+ /** Production: the runtime's own argon2id defaults, with nothing pinned by hand. */
56
+ export const PRODUCTION_ARGON2: Bun.Password.Argon2Algorithm = { algorithm: "argon2id" };
57
+
58
+ /** The floor argon2id accepts — free, and still argon2id. */
59
+ export const TEST_ARGON2: Bun.Password.Argon2Algorithm = {
60
+ algorithm: "argon2id",
61
+ memoryCost: 8,
62
+ timeCost: 1,
63
+ };
64
+
65
+ /**
66
+ * Which cost this process should pay. Takes `env` explicitly so the failure path is
67
+ * drivable: a check that can only be run under the runner it is deciding about is a
68
+ * check nobody can prove.
69
+ */
70
+ export function argon2Cost(env: NodeJS.ProcessEnv = process.env): Bun.Password.Argon2Algorithm {
71
+ return isTestRuntime(env) ? TEST_ARGON2 : PRODUCTION_ARGON2;
72
+ }
73
+
74
+ /**
75
+ * Hash a secret at the right cost for this process. The one call every app should use;
76
+ * a bare `Bun.password.hash(x, "argon2id")` pays production's cost in every test.
77
+ */
78
+ export const hashSecret = (secret: string): Promise<string> =>
79
+ Bun.password.hash(secret, argon2Cost());
80
+
81
+ /**
82
+ * Verify a secret against a stored argon2id PHC string — `false`, never a throw, on anything
83
+ * malformed. `Bun.password` on the Mac; on the Worker the same global is `worker/password.ts`'s
84
+ * pure-JS shim, which reproduces Bun's hashes byte for byte, so every stored verifier keeps working.
85
+ */
86
+ export const verifySecret = (secret: string, hash: string): Promise<boolean> =>
87
+ Bun.password.verify(secret, hash).catch(() => false);
@@ -80,3 +80,16 @@ export {
80
80
  type RouteCpuStats,
81
81
  } from './recorder.js';
82
82
  export { BENCH_ORIGIN, type BenchCase, runCpuBench, type RunCpuBenchOpts } from './runBench.js';
83
+ export {
84
+ parseTailLines,
85
+ type RecordTailTracesOpts,
86
+ recordTailTraces,
87
+ routeMatcher,
88
+ TAIL_CPU_SOURCE,
89
+ type TailRecordResult,
90
+ type TailTraceLike,
91
+ type TailTrafficRate,
92
+ tailCpuClock,
93
+ tailTrafficRate,
94
+ UNMATCHED_ROUTE,
95
+ } from './tail.js';
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The tail reading — proven in both directions against the SAME assertion the local proxy uses,
3
+ * because a real reader that could not go red would be worse than the proxy it replaces.
4
+ */
5
+ import { describe, expect, it } from 'bun:test';
6
+ import { checkCpuBudgets } from './assert.js';
7
+ import { createCpuRecorder } from './recorder.js';
8
+ import {
9
+ parseTailLines,
10
+ recordTailTraces,
11
+ routeMatcher,
12
+ tailCpuClock,
13
+ tailTrafficRate,
14
+ type TailTraceLike,
15
+ UNMATCHED_ROUTE,
16
+ } from './tail.js';
17
+ import { processCpuClock } from './cpuClock.js';
18
+
19
+ const BUDGETS = {
20
+ routes: {
21
+ 'GET /api/items/:id': 5,
22
+ 'GET /api/items/recent': 2,
23
+ 'POST /api/upload': { exempt: true as const, reason: 'hashing is the work' },
24
+ '/healthz': 1,
25
+ },
26
+ };
27
+
28
+ const trace = (method: string, path: string, cpuTime: number | undefined, at = 0): TailTraceLike => ({
29
+ cpuTime,
30
+ eventTimestamp: at,
31
+ scriptName: 'collections',
32
+ event: { request: { method, url: `https://collections.example${path}` } },
33
+ });
34
+
35
+ const many = (n: number, make: (i: number) => TailTraceLike): TailTraceLike[] => Array.from({ length: n }, (_, i) => make(i));
36
+
37
+ describe('routeMatcher', () => {
38
+ it('the most literal pattern wins, and the method must agree', () => {
39
+ const match = routeMatcher(Object.keys(BUDGETS.routes));
40
+ expect(match('GET', '/api/items/recent')).toBe('/api/items/recent');
41
+ expect(match('GET', '/api/items/abc123')).toBe('/api/items/:id');
42
+ expect(match('POST', '/api/items/abc123')).toBeNull();
43
+ expect(match('HEAD', '/healthz')).toBe('/healthz');
44
+ expect(match('GET', '/api/items/a/b')).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('recordTailTraces', () => {
49
+ it('stamps the report proxy:false with the tail as its source', () => {
50
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
51
+ recordTailTraces(recorder, many(30, (i) => trace('GET', `/api/items/${i}`, 1 + (i % 3))));
52
+ const report = recorder.report();
53
+ expect(report.proxy).toBe(false);
54
+ expect(report.source).toBe('tail-worker cpuTime');
55
+ expect(report.routes.map((r) => r.key)).toEqual(['GET /api/items/:id']);
56
+ });
57
+
58
+ it('🔴 refuses a PROXY recorder — real numbers may not be labelled a proxy, nor the reverse', () => {
59
+ const recorder = createCpuRecorder({ clock: processCpuClock(), config: BUDGETS });
60
+ expect(() => recordTailTraces(recorder, [])).toThrow(/is a PROXY/);
61
+ });
62
+
63
+ it('🔴 a trace with no cpuTime is skipped and counted — never recorded as a zero', () => {
64
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
65
+ const result = recordTailTraces(recorder, [
66
+ trace('GET', '/healthz', undefined),
67
+ { cpuTime: 3, event: null },
68
+ trace('GET', '/nope/1', 2),
69
+ ]);
70
+ expect(result).toMatchObject({ recorded: 1, noCpuTime: 1, notFetch: 1, unmatched: 1, unmatchedPaths: ['GET /nope/1'] });
71
+ expect(recorder.report().routes[0]?.route).toBe(UNMATCHED_ROUTE);
72
+ });
73
+
74
+ it('a tail recorder mounted as middleware fails loudly instead of measuring nothing', () => {
75
+ expect(() => tailCpuClock().start()).toThrow(/FED by recordTailTraces/);
76
+ });
77
+ });
78
+
79
+ describe('the SAME assertion, fed by the tail', () => {
80
+ it('passes a window whose readings move and sit under budget', () => {
81
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
82
+ recordTailTraces(recorder, many(40, (i) => trace('GET', `/api/items/${i}`, i % 4)));
83
+ expect(checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails)).toEqual([]);
84
+ });
85
+
86
+ it('🔴 goes RED on a route over its budget, and names it', () => {
87
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
88
+ recordTailTraces(recorder, many(40, (i) => trace('GET', '/api/items/recent', 3 + (i % 2))));
89
+ const failing = checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails);
90
+ expect(failing.map((v) => v.kind)).toEqual(['over-budget']);
91
+ expect(failing[0]?.key).toBe('GET /api/items/recent');
92
+ });
93
+
94
+ it('🔴 goes RED on traffic no declared route covers, under requireDeclared', () => {
95
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
96
+ recordTailTraces(recorder, many(40, (i) => trace('GET', `/undeclared/${i}`, 1 + (i % 2))));
97
+ const kinds = checkCpuBudgets(recorder.report(), { requireDeclared: true }).filter((v) => v.fails).map((v) => v.kind);
98
+ expect(kinds).toContain('undeclared');
99
+ });
100
+
101
+ it('🔴 a window where EVERY reading is 0 is still refused as degenerate', () => {
102
+ const recorder = createCpuRecorder({ clock: tailCpuClock(), config: BUDGETS });
103
+ recordTailTraces(recorder, many(40, (i) => trace('GET', `/api/items/${i}`, 0)));
104
+ expect(checkCpuBudgets(recorder.report()).map((v) => v.kind)).toEqual(['degenerate']);
105
+ });
106
+ });
107
+
108
+ describe('parseTailLines and tailTrafficRate', () => {
109
+ it('reads `wrangler tail --format json`, skipping its banner and any partial line', () => {
110
+ const text = [
111
+ 'Successfully created tail, expires at …',
112
+ JSON.stringify(trace('GET', '/healthz', 1, 1_000)),
113
+ '{"cpuTime": 2, "event": {"request": {"url": "https://x/healthz", "me',
114
+ '',
115
+ JSON.stringify(trace('GET', '/healthz', 2, 2_000)),
116
+ ].join('\n');
117
+ expect(parseTailLines(text).map((t) => t.cpuTime)).toEqual([1, 2]);
118
+ });
119
+
120
+ it('scales a window to requests/day — and refuses to scale one shorter than an hour', () => {
121
+ const hour = 3_600_000;
122
+ const two = [trace('GET', '/healthz', 1, 0), trace('GET', '/healthz', 1, 2 * hour), { cpuTime: 5 }];
123
+ expect(tailTrafficRate(two).requestsPerDay).toBe(24);
124
+ expect(Number.isNaN(tailTrafficRate([trace('GET', '/a', 1, 0), trace('GET', '/a', 1, 60_000)]).requestsPerDay)).toBe(true);
125
+ });
126
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The REAL reading: Worker CPU-ms off a tail worker's trace events, fed into the same recorder
3
+ * and the same assertion the local proxy uses.
4
+ *
5
+ * ## Why this shape, and not a clock
6
+ *
7
+ * {@link workerCpuClock} is a clock — `start()` then read again — and the isolate never hands a
8
+ * handler its own CPU time, so there is nothing for it to read inside a request. What Cloudflare
9
+ * DOES publish is one `cpuTime` per invocation on the trace event a TAIL worker receives (the
10
+ * same field `wrangler tail --format json` prints — `apps/binary-server/scripts/edgeTail.ts`
11
+ * already reads it there). So the real number arrives AFTER the request, per request, and the
12
+ * honest API is to record it then: {@link recordTailTraces} turns trace events into samples on a
13
+ * recorder built with {@link tailCpuClock}, which stamps `proxy: false`.
14
+ *
15
+ * Two ways to get the events, and a consumer needs neither code nor a new Worker for the first:
16
+ *
17
+ * · **`wrangler tail <worker> --format json`** during a bench or a smoke against the live
18
+ * Worker — one JSON trace per line, parsed with {@link parseTailLines}.
19
+ * · **a Tail Worker** (`tail_consumers` in the producer's wrangler config) whose `tail(events)`
20
+ * hands the array straight to {@link recordTailTraces}.
21
+ *
22
+ * 🔴 The degenerate guard still applies, deliberately. `cpuTime` is whole milliseconds, so a
23
+ * route that genuinely costs under 1 ms reads 0 — and a report where EVERY reading is 0 is still
24
+ * refused, because that is indistinguishable from a reader that is not reading. One real route
25
+ * above a millisecond anywhere in the window clears it; `allowZeroCpu` is not the fix.
26
+ *
27
+ * ## The traffic half
28
+ *
29
+ * {@link tailTrafficRate} is requests per day over the window the events span — the number
30
+ * `deriveCpuBudgetMs({ requestsPerDay })` needs to re-derive the default from THIS generation's
31
+ * Worker traffic (tasks 342/377) instead of the retired generation's corpus.
32
+ */
33
+
34
+ import type { CpuClock } from './cpuClock.js';
35
+ import type { CpuRecorder } from './recorder.js';
36
+
37
+ /** The fields of a Cloudflare `TraceItem` this reads — structural, so no workers-types import. */
38
+ export interface TailTraceLike {
39
+ /** CPU-ms of the whole invocation, as billed. Absent on runtimes that do not report it. */
40
+ cpuTime?: number | null;
41
+ wallTime?: number | null;
42
+ /** Epoch ms. */
43
+ eventTimestamp?: number | null;
44
+ outcome?: string | null;
45
+ scriptName?: string | null;
46
+ /** A fetch invocation carries `request`; a cron or queue invocation does not. */
47
+ event?: { request?: { url?: string; method?: string } | null } | null;
48
+ }
49
+
50
+ /** Provenance printed on every report built from tail events. */
51
+ export const TAIL_CPU_SOURCE = 'tail-worker cpuTime';
52
+
53
+ /**
54
+ * The clock for a recorder that is FED rather than started. `proxy: false`: this is the quantity
55
+ * Cloudflare bills. Its `start()` throws — a tail recorder mounted as `cpuBudget()` middleware is
56
+ * a wiring mistake, and a span that silently measured nothing is the lie this module refuses.
57
+ */
58
+ export function tailCpuClock(source = TAIL_CPU_SOURCE): CpuClock {
59
+ return {
60
+ source,
61
+ proxy: false,
62
+ available: true,
63
+ start() {
64
+ throw new Error(
65
+ 'tailCpuClock: a tail recorder is FED by recordTailTraces(), never started — the isolate ' +
66
+ 'does not hand a handler its own CPU time. Mount cpuBudget() with processCpuClock() ' +
67
+ 'for the local proxy, and feed this recorder from a tail.',
68
+ );
69
+ },
70
+ };
71
+ }
72
+
73
+ /** A route key's pattern half: `'GET /api/items/:id'` → `/api/items/:id`, with its method. */
74
+ function splitKey(key: string): { method: string | null; pattern: string } {
75
+ const m = /^([A-Z]+)\s+(\S+)$/.exec(key.trim());
76
+ return m ? { method: m[1] ?? null, pattern: m[2] ?? key } : { method: null, pattern: key.trim() };
77
+ }
78
+
79
+ /** Hono-style pattern → anchored regex. `:param` (with an optional `{re}`) is one segment, `*` is the rest. */
80
+ function patternRegex(pattern: string): RegExp {
81
+ let out = '';
82
+ for (let i = 0; i < pattern.length; ) {
83
+ const rest = pattern.slice(i);
84
+ const param = /^:[A-Za-z0-9_]+(\{[^}]*\})?\??/.exec(rest);
85
+ if (param) {
86
+ out += '[^/]+';
87
+ i += param[0].length;
88
+ continue;
89
+ }
90
+ const ch = pattern[i] as string;
91
+ out += ch === '*' ? '.*' : ch.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
92
+ i += 1;
93
+ }
94
+ return new RegExp(`^${out}$`);
95
+ }
96
+
97
+ /** How literal a pattern is — the more literal characters, the earlier it is tried. */
98
+ const specificity = (pattern: string): number =>
99
+ pattern.replace(/:[A-Za-z0-9_]+(\{[^}]*\})?\??/g, '').replace(/\*/g, '').length -
100
+ (pattern.includes('*') ? 1000 : 0);
101
+
102
+ /**
103
+ * A `(method, path) → route label` matcher over declared route keys — the recorder's own
104
+ * `config.routes`, by default — so an app gets tail attribution with no code of its own. The
105
+ * most literal pattern wins, and a method-specific key beats an any-method one.
106
+ */
107
+ export function routeMatcher(keys: readonly string[]): (method: string, path: string) => string | null {
108
+ const compiled = keys
109
+ .map((key) => ({ ...splitKey(key), key }))
110
+ .map((k) => ({ ...k, re: patternRegex(k.pattern), score: specificity(k.pattern) + (k.method ? 0.5 : 0) }))
111
+ .sort((a, b) => b.score - a.score);
112
+ return (method, path) => {
113
+ for (const k of compiled) {
114
+ if (k.method && k.method !== method.toUpperCase()) continue;
115
+ if (k.re.test(path)) return k.pattern;
116
+ }
117
+ return null;
118
+ };
119
+ }
120
+
121
+ /** The label traffic that matched no declared route is recorded under — so `requireDeclared` names it. */
122
+ export const UNMATCHED_ROUTE = '(no declared route)';
123
+
124
+ export interface RecordTailTracesOpts {
125
+ /** Map a request to its route label. Default: {@link routeMatcher} over the recorder's declared routes. */
126
+ route?: (method: string, path: string) => string | null;
127
+ /** Only this Worker's invocations — a tail consumer may receive several producers. */
128
+ scriptName?: string;
129
+ }
130
+
131
+ export interface TailRecordResult {
132
+ recorded: number;
133
+ /** Invocations with no request (cron, queue) — CPU, but no route to charge it to. */
134
+ notFetch: number;
135
+ /** Fetch invocations whose trace carried no finite `cpuTime`. */
136
+ noCpuTime: number;
137
+ /** Recorded under {@link UNMATCHED_ROUTE}; up to ten distinct paths, so the gap is nameable. */
138
+ unmatched: number;
139
+ unmatchedPaths: string[];
140
+ }
141
+
142
+ /**
143
+ * Charge each fetch invocation's billed `cpuTime` to its route on `recorder`.
144
+ *
145
+ * 🔴 A trace with no finite `cpuTime` is NOT a zero — it is skipped and counted, so a runtime
146
+ * that stopped reporting the field reads as "nothing recorded" (and the assertion's `no-samples`
147
+ * guard reds it) rather than as a confident route that costs nothing.
148
+ */
149
+ export function recordTailTraces(
150
+ recorder: CpuRecorder,
151
+ traces: readonly TailTraceLike[],
152
+ opts: RecordTailTracesOpts = {},
153
+ ): TailRecordResult {
154
+ if (recorder.clock.proxy) {
155
+ throw new Error(
156
+ `recordTailTraces: the recorder's clock '${recorder.clock.source}' is a PROXY. Tail cpuTime is the ` +
157
+ 'billed quantity — build this recorder with tailCpuClock(), or its report will call real numbers a proxy.',
158
+ );
159
+ }
160
+ const route = opts.route ?? routeMatcher(Object.keys(recorder.config.routes ?? {}));
161
+ const result: TailRecordResult = { recorded: 0, notFetch: 0, noCpuTime: 0, unmatched: 0, unmatchedPaths: [] };
162
+ for (const trace of traces) {
163
+ if (opts.scriptName && trace.scriptName && trace.scriptName !== opts.scriptName) continue;
164
+ const request = trace.event?.request;
165
+ if (!request?.url) {
166
+ result.notFetch += 1;
167
+ continue;
168
+ }
169
+ const cpu = trace.cpuTime;
170
+ if (typeof cpu !== 'number' || !Number.isFinite(cpu)) {
171
+ result.noCpuTime += 1;
172
+ continue;
173
+ }
174
+ const method = (request.method ?? 'GET').toUpperCase();
175
+ let path: string;
176
+ try {
177
+ path = new URL(request.url).pathname;
178
+ } catch {
179
+ path = request.url;
180
+ }
181
+ const label = route(method, path);
182
+ if (label === null) {
183
+ result.unmatched += 1;
184
+ if (result.unmatchedPaths.length < 10 && !result.unmatchedPaths.includes(`${method} ${path}`)) {
185
+ result.unmatchedPaths.push(`${method} ${path}`);
186
+ }
187
+ }
188
+ recorder.record(method, label ?? UNMATCHED_ROUTE, cpu);
189
+ result.recorded += 1;
190
+ }
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * `wrangler tail --format json` output → trace events. Tolerates the banner lines wrangler
196
+ * prints and blank lines; a line that is not a JSON object is skipped, never guessed at.
197
+ */
198
+ export function parseTailLines(text: string): TailTraceLike[] {
199
+ const out: TailTraceLike[] = [];
200
+ for (const line of text.split('\n')) {
201
+ const trimmed = line.trim();
202
+ if (!trimmed.startsWith('{')) continue;
203
+ try {
204
+ const parsed = JSON.parse(trimmed) as unknown;
205
+ if (parsed && typeof parsed === 'object') out.push(parsed as TailTraceLike);
206
+ } catch {
207
+ // a partial line from an interrupted tail — not a trace
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ export interface TailTrafficRate {
214
+ /** Fetch invocations in the window. */
215
+ requests: number;
216
+ /** First to last `eventTimestamp`, ms. */
217
+ spanMs: number;
218
+ /** `requests` scaled to a day — `NaN` when the window is too short to scale honestly. */
219
+ requestsPerDay: number;
220
+ }
221
+
222
+ /**
223
+ * Requests per day over the window the traces span — what `deriveCpuBudgetMs({ requestsPerDay })`
224
+ * takes. `NaN` below an hour of window: scaling ten minutes of traffic to a day is a guess with a
225
+ * decimal point, and a budget derived from it would be quoted as a measurement.
226
+ */
227
+ export function tailTrafficRate(traces: readonly TailTraceLike[], minSpanMs = 60 * 60 * 1000): TailTrafficRate {
228
+ const stamps = traces
229
+ .filter((t) => t.event?.request?.url)
230
+ .map((t) => t.eventTimestamp)
231
+ .filter((s): s is number => typeof s === 'number' && Number.isFinite(s))
232
+ .sort((a, b) => a - b);
233
+ const requests = stamps.length;
234
+ const spanMs = requests > 1 ? (stamps[requests - 1] as number) - (stamps[0] as number) : 0;
235
+ const requestsPerDay = spanMs >= minSpanMs ? (requests / spanMs) * 86_400_000 : Number.NaN;
236
+ return { requests, spanMs, requestsPerDay };
237
+ }
@@ -7,11 +7,10 @@
7
7
  * local driver is deliberately as strict as D1 rather than as lenient as Bun.
8
8
  *
9
9
  * ```ts
10
- * import { createLocalD1, createRemoteD1, createD1Kysely } from 'cursedbelt-server/d1';
10
+ * import { createLocalD1, createRemoteD1 } from 'cursedbelt-server/d1';
11
11
  *
12
12
  * const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
13
13
  * const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
14
- * const ky = createD1Kysely(db);
15
14
  * ```
16
15
  */
17
16
 
@@ -32,13 +31,14 @@ export {
32
31
  // as the note below, one step wider: a barrel must reach neither an optional peer nor a
33
32
  // runtime the target platform lacks. `barrelsReachNoOptionalPeer.spec.ts` checks both.
34
33
  //
35
- // 🔴 `./kysely` is deliberately NOT re-exported here — import it from
36
- // `cursedbelt-server/d1/kysely`. It statically imports `kysely` (real values: `Kysely`,
37
- // `SqliteAdapter`, `SqliteQueryCompiler`), which is an OPTIONAL peer, so re-exporting it
38
- // made `import 'cursedbelt-server/d1'` throw `Cannot find package 'kysely'` for every app
39
- // that does not use the query builder — which per `../db/kysely.ts`'s own header is most
40
- // of them, since business tables stay on raw statements. Measured 2026-09-18 against the
41
- // published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
34
+ // `./kysely` — a Kysely dialect over this seam, split out as its own `d1/kysely` subpath on
35
+ // 2026-09-18 because it dragged the OPTIONAL `kysely` peer into every `import '…/d1'` — was
36
+ // REMOVED in 4.19.0 (task 472). Measured 2026-09-23: no file under the generation imported
37
+ // it, no app depends on `kysely` at all, and every ported Worker (patterns, collections,
38
+ // music, vault) reached D1 through raw statements on this seam. A dialect nobody wants is
39
+ // surface with a peer attached; if a ported app ever wants a query builder, it comes back
40
+ // with that app as its first consumer, and `barrelsReachNoOptionalPeer.spec.ts` still keeps
41
+ // it out of this barrel.
42
42
  export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
43
43
  export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
44
44
  export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The one door engagement needs, and the in-process read of what it recorded.
3
+ *
4
+ * POST /api/engagement/view → the beacon. SESSION-gated; the user id comes
5
+ * from the session, never from the body.
6
+ *
7
+ * 🔴 **There is no HTTP read surface, on purpose** (task `113`/`268`, 2026-09-22).
8
+ * `GET /api/admin/engagement` used to sit here behind `SATELLITE_METRICS_TOKEN` —
9
+ * ONE bearer shared across five production env files, so a leak from any app was
10
+ * a read on all of them — for a fleet console that lives in the retired
11
+ * generation. Nothing in this generation read it over HTTP: station's telemetry
12
+ * page reads each app's sqlite straight off the disk, and the analytics reader
13
+ * planned after it does the same with `engagement.sqlite`. Recording carries on
14
+ * regardless; `readAppEngagement` below is the snapshot a same-machine reader
15
+ * computes from the store. If an HTTP reader is ever wanted, it gets a PER-APP
16
+ * credential from the generation's secrets store, never a fleet-wide one.
17
+ *
18
+ * ── Why the user id is never in the body ────────────────────────────────────
19
+ * A beacon whose payload names its own user is an endpoint that lets anyone
20
+ * write anyone's history — and on a private household app that history is the
21
+ * data. `resolveUser` is supplied by the app's own SSO consumer and is the ONLY
22
+ * source of a user key; a request that resolves to nobody is a 401 and writes
23
+ * nothing. `engagement.test.ts` pins that a body-supplied `userKey` is ignored.
24
+ *
25
+ * ── Why the recorder is mounted AFTER the session gate ──────────────────────
26
+ * The opposite of the feedback-kit sync receiver, and for the mirrored reason:
27
+ * this door has no credential of its own and wants the gate to have run,
28
+ * because that is what puts the user where `resolveUser` can read them — while
29
+ * a sync receiver carries its own bearer and must mount BEFORE the gate. The
30
+ * rule and its reason: `docs/engagement.md` §2 in this package.
31
+ */
32
+ import { Hono } from "hono";
33
+ import type { Context } from "hono";
34
+ import { OTHER_PLACE, type PlaceRef, resolvePlace } from "./policy.js";
35
+ import type { EngagementStore } from "./store.js";
36
+ import { type AppEngagement, returnCurve, summarize } from "./summary.js";
37
+
38
+ export interface EngagementApiOptions {
39
+ store: EngagementStore;
40
+ /** The app's own name. */
41
+ app: string;
42
+ /**
43
+ * The allowlist of place ids — the app's `routes.manifest.json` ids. Anything
44
+ * a client claims that is not in here records as `other`; see `policy.ts`
45
+ * rule 1 for why this is the whole privacy story.
46
+ */
47
+ places: readonly PlaceRef[];
48
+ /** Resolve the CURRENT request's account. Null → 401, nothing recorded. */
49
+ resolveUser: (c: Context) => string | null;
50
+ /** Injectable clock, so the tests are not timing-dependent. */
51
+ now?: () => number;
52
+ }
53
+
54
+ /** The recorder half — session-gated, mounted at `/api/engagement`. */
55
+ export function createEngagementRecorder(options: EngagementApiOptions): Hono {
56
+ const app = new Hono();
57
+ const now = options.now ?? (() => Date.now());
58
+
59
+ app.post("/view", async (c) => {
60
+ const userKey = options.resolveUser(c);
61
+ if (!userKey) return c.json({ error: "unauthenticated" }, 401);
62
+
63
+ let body: unknown;
64
+ try {
65
+ body = await c.req.json();
66
+ } catch {
67
+ return c.json({ error: "expected a json body" }, 400);
68
+ }
69
+ const claimed = (body as { place?: unknown } | null)?.place;
70
+ if (typeof claimed !== "string") return c.json({ error: "place must be a string" }, 400);
71
+ // 🔴 Resolution happens HERE, at the door, against the app's own manifest.
72
+ // The store does not re-check; see its `recordView` header for why one
73
+ // check in the right place beats two in the wrong ones.
74
+ const place = resolvePlace(claimed, options.places);
75
+
76
+ const fold = options.store.recordView({ userKey, place, ts: now() });
77
+ // Echo the RESOLVED place, not the claimed one. A client that sent
78
+ // something off-manifest learns that it was bucketed, which is how a
79
+ // missing manifest entry gets noticed instead of quietly reading as an
80
+ // unpopular feature.
81
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
82
+ });
83
+
84
+ return app;
85
+ }
86
+
87
+ /** The whole snapshot one app publishes about itself. */
88
+ export interface AppEngagementSnapshot extends AppEngagement {
89
+ /** The manifest ids this app CAN report — so a reader can show a place
90
+ * with zero views, which is the half of the question that is easy to lose. */
91
+ knownPlaces: string[];
92
+ returnCurve: Array<{ day: string; joined: number; returned: number }>;
93
+ retention: { days: number; places: number; users: number; oldestDay: string | null };
94
+ }
95
+
96
+ export function readAppEngagement(
97
+ store: EngagementStore,
98
+ app: string,
99
+ now: number,
100
+ knownPlaces: readonly PlaceRef[] = [],
101
+ ): AppEngagementSnapshot {
102
+ const users = store.users();
103
+ const summary = summarize({
104
+ app,
105
+ now,
106
+ users,
107
+ places: store.places(),
108
+ // 120 days is the store's whole daily retention — asking for more would
109
+ // silently return less, which reads as a quiet period rather than as a
110
+ // window boundary.
111
+ days: store.days(now - 120 * 86_400_000),
112
+ });
113
+ return {
114
+ ...summary,
115
+ knownPlaces: knownPlaces.map((p) => p.id),
116
+ returnCurve: returnCurve(users, now),
117
+ retention: store.stats(),
118
+ };
119
+ }