cursedbelt-server 4.18.0 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/d1/pullD1.js +16 -3
  12. package/dist/server/engagement/api.d.ts +71 -0
  13. package/dist/server/engagement/api.js +84 -0
  14. package/dist/server/engagement/env.d.ts +18 -0
  15. package/dist/server/engagement/env.js +52 -0
  16. package/dist/server/engagement/index.d.ts +55 -0
  17. package/dist/server/engagement/index.js +55 -0
  18. package/dist/server/engagement/places.d.ts +22 -0
  19. package/dist/server/engagement/places.js +63 -0
  20. package/dist/server/engagement/policy.d.ts +168 -0
  21. package/dist/server/engagement/policy.js +202 -0
  22. package/dist/server/engagement/store.d.ts +92 -0
  23. package/dist/server/engagement/store.js +223 -0
  24. package/dist/server/engagement/summary.d.ts +102 -0
  25. package/dist/server/engagement/summary.js +127 -0
  26. package/dist/server/engagement/types.d.ts +42 -0
  27. package/dist/server/engagement/types.js +12 -0
  28. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  29. package/dist/server/maps-budget/mapsBudget.js +193 -0
  30. package/dist/server/satellite/config.d.ts +173 -0
  31. package/dist/server/satellite/config.js +259 -0
  32. package/dist/server/satellite/door.d.ts +112 -0
  33. package/dist/server/satellite/door.js +149 -0
  34. package/dist/server/storage/binaryStore.d.ts +18 -0
  35. package/dist/server/storage/binaryStore.js +32 -1
  36. package/dist/server/storage/derivatives.d.ts +253 -0
  37. package/dist/server/storage/derivatives.js +266 -0
  38. package/dist/server/storage/uploadSession.d.ts +75 -0
  39. package/dist/server/storage/uploadSession.js +74 -0
  40. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  41. package/docs/activity.md +43 -0
  42. package/docs/engagement.md +47 -0
  43. package/docs/notifications.md +43 -0
  44. package/docs/retention.md +81 -0
  45. package/docs/skipped-tests.md +19 -0
  46. package/package.json +46 -9
  47. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  48. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  49. package/src/server/activity/index.ts +2 -1
  50. package/src/server/auth/passwordCost.spec.ts +42 -0
  51. package/src/server/auth/passwordCost.ts +87 -0
  52. package/src/server/bench/index.ts +13 -0
  53. package/src/server/bench/tail.spec.ts +126 -0
  54. package/src/server/bench/tail.ts +237 -0
  55. package/src/server/d1/index.ts +9 -9
  56. package/src/server/d1/pullD1.spec.ts +20 -0
  57. package/src/server/d1/pullD1.ts +18 -2
  58. package/src/server/engagement/api.ts +119 -0
  59. package/src/server/engagement/engagement.spec.ts +462 -0
  60. package/src/server/engagement/env.ts +73 -0
  61. package/src/server/engagement/index.ts +92 -0
  62. package/src/server/engagement/places.ts +76 -0
  63. package/src/server/engagement/policy.ts +250 -0
  64. package/src/server/engagement/store.ts +272 -0
  65. package/src/server/engagement/summary.ts +216 -0
  66. package/src/server/engagement/types.ts +61 -0
  67. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  68. package/src/server/maps-budget/mapsBudget.ts +304 -0
  69. package/src/server/satellite/config.ts +389 -0
  70. package/src/server/satellite/door.ts +169 -0
  71. package/src/server/satellite/satellite.spec.ts +161 -0
  72. package/src/server/storage/binaryStore.ts +31 -1
  73. package/src/server/storage/derivatives.spec.ts +125 -0
  74. package/src/server/storage/derivatives.ts +329 -0
  75. package/src/server/storage/uploadSession.spec.ts +132 -0
  76. package/src/server/storage/uploadSession.ts +114 -0
  77. package/dist/server/d1/kysely.d.ts +0 -56
  78. package/dist/server/d1/kysely.js +0 -138
  79. package/src/server/d1/kysely.spec.ts +0 -145
  80. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,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';
@@ -172,6 +172,26 @@ describe('pulling D1 down — table by table, because a whole-database export re
172
172
  ).rejects.toThrow(/lists no `tracks` table/);
173
173
  });
174
174
 
175
+ test('an export that says success and writes NO file is asked again — and refuses if it never does', async () => {
176
+ const dir = scratch();
177
+ let flaky = 1;
178
+ const run: Wrangler = (args) => {
179
+ if (args[1] === 'execute') return { code: 0, stdout: JSON.stringify([{ results: MASTER }]), stderr: '' };
180
+ const table = args[args.indexOf('--table') + 1] as string;
181
+ const out = args[args.indexOf('--output') + 1] as string;
182
+ // `meta` "succeeds" once without writing — measured on collections, 2026-09-23.
183
+ if (table === 'meta' && flaky-- > 0) return { code: 0, stdout: 'Downloaded successfully', stderr: '' };
184
+ writeFileSync(out, table === 'items' ? "INSERT INTO items (id) VALUES ('i1');" : '');
185
+ return { code: 0, stdout: '', stderr: '' };
186
+ };
187
+ await pull(dir, run);
188
+ const never: Wrangler = (args) =>
189
+ args[1] === 'execute'
190
+ ? { code: 0, stdout: JSON.stringify([{ results: MASTER }]), stderr: '' }
191
+ : { code: 0, stdout: 'Downloaded successfully', stderr: '' };
192
+ await expect(pull(scratch(), never)).rejects.toThrow(/reported success 3 times and wrote no file/);
193
+ });
194
+
175
195
  test('🔴 one table failing to export THROWS — never a snapshot missing that table', async () => {
176
196
  const dir = scratch();
177
197
  const { run } = fake((table) => (table === 'meta' ? null : "INSERT INTO items (id) VALUES ('x');"));
@@ -97,6 +97,9 @@ export async function servedRuntime(
97
97
  return null;
98
98
  }
99
99
 
100
+ /** How many times one table's export is asked for when wrangler says success and writes nothing. */
101
+ const EXPORT_ATTEMPTS = 3;
102
+
100
103
  /** One `wrangler` invocation, as {@link pullD1ToSqlite} needs it. Injected so a spec runs none. */
101
104
  export type Wrangler = (args: string[]) => { code: number; stdout: string; stderr: string };
102
105
 
@@ -160,11 +163,24 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
160
163
  let sql = '';
161
164
  for (const table of tables) {
162
165
  const dump = `${destination}.${table}.sql`;
163
- const exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
166
+ // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
167
+ // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
168
+ // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
169
+ // asked for again, twice, before it is a refusal; a failure that persists still refuses,
170
+ // because a snapshot missing a table is not a backup.
171
+ let exported = { code: 1, stdout: '', stderr: 'not attempted' };
172
+ for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
173
+ exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
174
+ if (exported.code !== 0 || existsSync(dump)) break;
175
+ }
164
176
  if (exported.code !== 0) {
165
177
  throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
166
178
  }
167
- if (!existsSync(dump)) throw new Error(`wrangler d1 export --table ${table} reported success and wrote no file at ${dump}`);
179
+ if (!existsSync(dump)) {
180
+ throw new Error(
181
+ `wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`,
182
+ );
183
+ }
168
184
  sql += `${readFileSync(dump, 'utf8')}\n`;
169
185
  }
170
186
  if (!/INSERT INTO/i.test(sql)) {
@@ -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
+ }