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,84 @@
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 { OTHER_PLACE, resolvePlace } from "./policy.js";
34
+ import { returnCurve, summarize } from "./summary.js";
35
+ /** The recorder half — session-gated, mounted at `/api/engagement`. */
36
+ export function createEngagementRecorder(options) {
37
+ const app = new Hono();
38
+ const now = options.now ?? (() => Date.now());
39
+ app.post("/view", async (c) => {
40
+ const userKey = options.resolveUser(c);
41
+ if (!userKey)
42
+ return c.json({ error: "unauthenticated" }, 401);
43
+ let body;
44
+ try {
45
+ body = await c.req.json();
46
+ }
47
+ catch {
48
+ return c.json({ error: "expected a json body" }, 400);
49
+ }
50
+ const claimed = body?.place;
51
+ if (typeof claimed !== "string")
52
+ return c.json({ error: "place must be a string" }, 400);
53
+ // 🔴 Resolution happens HERE, at the door, against the app's own manifest.
54
+ // The store does not re-check; see its `recordView` header for why one
55
+ // check in the right place beats two in the wrong ones.
56
+ const place = resolvePlace(claimed, options.places);
57
+ const fold = options.store.recordView({ userKey, place, ts: now() });
58
+ // Echo the RESOLVED place, not the claimed one. A client that sent
59
+ // something off-manifest learns that it was bucketed, which is how a
60
+ // missing manifest entry gets noticed instead of quietly reading as an
61
+ // unpopular feature.
62
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
63
+ });
64
+ return app;
65
+ }
66
+ export function readAppEngagement(store, app, now, knownPlaces = []) {
67
+ const users = store.users();
68
+ const summary = summarize({
69
+ app,
70
+ now,
71
+ users,
72
+ places: store.places(),
73
+ // 120 days is the store's whole daily retention — asking for more would
74
+ // silently return less, which reads as a quiet period rather than as a
75
+ // window boundary.
76
+ days: store.days(now - 120 * 86_400_000),
77
+ });
78
+ return {
79
+ ...summary,
80
+ knownPlaces: knownPlaces.map((p) => p.id),
81
+ returnCurve: returnCurve(users, now),
82
+ retention: store.stats(),
83
+ };
84
+ }
@@ -0,0 +1,18 @@
1
+ export interface EngagementEnv {
2
+ enabled: boolean;
3
+ /** Absolute path of the engagement database, or null when disabled. */
4
+ dbPath: string | null;
5
+ /** Why it is off, when it is — surfaced in the app-info overlay. */
6
+ reason: string | null;
7
+ }
8
+ export interface EngagementEnvOptions {
9
+ /**
10
+ * Where this app keeps its data — the app's own resolver, because WHERE an app's data
11
+ * lives is the app's decision, not this library's. It may THROW when there is no honest
12
+ * answer (a test runner, an unset state root); that turns recording off rather than
13
+ * guessing. Defaults to `cursedbelt-server/satellite-config`'s `resolveAppDataDir` — the same
14
+ * call every app's copy made — so `engagement.sqlite` sits beside the app's own database.
15
+ */
16
+ dataDir?: (app: string, env: NodeJS.ProcessEnv) => string;
17
+ }
18
+ export declare function readEngagementEnv(app: string, env?: NodeJS.ProcessEnv, options?: EngagementEnvOptions): EngagementEnv;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whether a satellite records engagement — read from the environment in one
3
+ * place, mirroring `metrics/env.ts` so the two are read the same way.
4
+ *
5
+ * ── The rules, and where they differ from metrics ───────────────────────────
6
+ * 1. **A test runner is never metered.** Same reason as metrics: constructing a
7
+ * store in a suite that named no data directory would throw and take the
8
+ * app's whole suite with it.
9
+ * 2. **Recording only — there is no read surface to configure.** The shared
10
+ * `SATELLITE_METRICS_TOKEN` bearer that once unlocked `/api/admin/engagement`
11
+ * is gone, and with it the route (`api.ts` says why). An app keeps its own
12
+ * history; a reader on this Mac opens `engagement.sqlite` directly.
13
+ * 3. 🔴 **An excluded app is off no matter what the environment says.** This is
14
+ * the one that is NOT a mirror of metrics. `engagementAllowedForApp` is
15
+ * consulted first and cannot be overridden by an env var, because the vault's
16
+ * exclusion must not be one `SATELLITE_ENGAGEMENT=1` away from being undone
17
+ * on a box nobody is looking at.
18
+ * 4. **`SATELLITE_ENGAGEMENT=0` turns it off** for an app that is otherwise
19
+ * allowed. One escape hatch, in the safe direction only.
20
+ */
21
+ import { join } from "node:path";
22
+ import { isTestRuntime } from "../satellite/door.js";
23
+ import { resolveAppDataDir } from "../satellite/config.js";
24
+ import { engagementAllowedForApp } from "./policy.js";
25
+ const appDataDir = (app, env) => resolveAppDataDir({ app, env });
26
+ export function readEngagementEnv(app, env = process.env, options = {}) {
27
+ const off = (reason) => ({
28
+ enabled: false,
29
+ dbPath: null,
30
+ reason,
31
+ });
32
+ // Rule 3 first, deliberately: no later branch can turn this back on.
33
+ if (!engagementAllowedForApp(app))
34
+ return off(`${app} is excluded from engagement by the kit`);
35
+ if (env.SATELLITE_ENGAGEMENT === "0")
36
+ return off("SATELLITE_ENGAGEMENT=0");
37
+ if (isTestRuntime(env))
38
+ return off("test runtime");
39
+ try {
40
+ return {
41
+ enabled: true,
42
+ dbPath: join((options.dataDir ?? appDataDir)(app, env), "engagement.sqlite"),
43
+ reason: null,
44
+ };
45
+ }
46
+ catch {
47
+ // The resolver refuses rather than guessing where data belongs.
48
+ // "Then don't" is the right answer for telemetry — the app boots, it
49
+ // simply has no history.
50
+ return off("no resolvable data directory");
51
+ }
52
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `cursedbelt-server/engagement` — the SERVER half of the fleet's product analytics: the
3
+ * recorder, the store, the policy and the summary. Bun-only (`bun:sqlite`).
4
+ *
5
+ * ── Why it is here, since 4.19.0 (task 280) ─────────────────────────────────
6
+ * It was `src/kit/engagement/` copied whole into `roms`, `family`, `music` and `collections`.
7
+ * `policy.ts` holds the three privacy rules that make it safe to run USER-KEYED analytics
8
+ * inside private household apps, and four copies of a privacy promise is four promises, with
9
+ * nothing keeping them the same — three of the twelve files had already forked (in `music`,
10
+ * doc comments only; the behaviour was identical in all four, so the plurality's text is what
11
+ * came across). The browser half — the beacon — is React and lives in the design system's tier;
12
+ * it depends on nothing here but the wire shape `POST <mount>/view {place}`.
13
+ *
14
+ * The split it depends on:
15
+ *
16
+ * metrics/ "is the fleet healthy" — throughput, latency, status codes. Promises it
17
+ * holds NO user id, in every app.
18
+ * engagement/ "is anyone using it, who, and what do they like" — per user, per app,
19
+ * per PLACE, plus a drop-off signal. User-keyed by definition, which is why
20
+ * it is a different store (`engagement.sqlite`).
21
+ *
22
+ * Read `policy.ts` before changing anything: the three rules (allowlisted places, apps
23
+ * excluded by name, counts-and-clocks only) are structural, not conventions.
24
+ *
25
+ * ── 🔴 Mount order: AFTER the session gate (`docs/engagement.md`) ─────────────
26
+ * The recorder has no credential of its own; the user key comes ONLY from the app's
27
+ * `resolveUser(c)`, which needs the gate to have run. That is the mirror image of a sync
28
+ * receiver, which carries its own bearer and must mount BEFORE the gate.
29
+ *
30
+ * ── 🔴 What stays in the app ────────────────────────────────────────────────
31
+ * `routes.manifest.json` and the test that proves it parses. `engagementPlacesFor` returns
32
+ * `[]` for anything it cannot read — silently, by design, so telemetry never takes a boot
33
+ * down — which degrades the allowlist to nothing while the board goes on rendering. Only the
34
+ * app can prove its own manifest parses; `apps/roms/routes.manifest.test.ts` is the pattern.
35
+ *
36
+ * ```ts
37
+ * import { createEngagementRecorder, EngagementStore, engagementPlacesFor, readEngagementEnv }
38
+ * from "cursedbelt-server/engagement";
39
+ *
40
+ * const setting = readEngagementEnv("myapp"); // beside the app's own data, or off
41
+ * if (setting.enabled && setting.dbPath) {
42
+ * const store = new EngagementStore({ dbPath: setting.dbPath });
43
+ * app.route("/api/engagement", createEngagementRecorder({
44
+ * store, app: "myapp", places: engagementPlacesFor(import.meta.dir),
45
+ * resolveUser: (c) => c.get("user")?.id ?? null,
46
+ * }));
47
+ * }
48
+ * ```
49
+ */
50
+ export { type AppEngagementSnapshot, createEngagementRecorder, type EngagementApiOptions, readAppEngagement, } from "./api.js";
51
+ export { type EngagementEnv, type EngagementEnvOptions, readEngagementEnv } from "./env.js";
52
+ export { type EngagementPlace, engagementPlacesFor } from "./places.js";
53
+ export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, type InterestVerdict, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, type PlaceRef, resolvePlace, SESSION_GAP_MS, type ViewFold, } from "./policy.js";
54
+ export { ENGAGEMENT_DAY_RETENTION_DAYS, type EngagementDay, EngagementStore, type EngagementStoreOptions, type EngagementUser, type EngagementUserPlace, } from "./store.js";
55
+ export { type AppEngagement, type DailyEngagement, type PlaceEngagement, returnCurve, summarize, type SummarizeInput, type UserEngagement, } from "./summary.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `cursedbelt-server/engagement` — the SERVER half of the fleet's product analytics: the
3
+ * recorder, the store, the policy and the summary. Bun-only (`bun:sqlite`).
4
+ *
5
+ * ── Why it is here, since 4.19.0 (task 280) ─────────────────────────────────
6
+ * It was `src/kit/engagement/` copied whole into `roms`, `family`, `music` and `collections`.
7
+ * `policy.ts` holds the three privacy rules that make it safe to run USER-KEYED analytics
8
+ * inside private household apps, and four copies of a privacy promise is four promises, with
9
+ * nothing keeping them the same — three of the twelve files had already forked (in `music`,
10
+ * doc comments only; the behaviour was identical in all four, so the plurality's text is what
11
+ * came across). The browser half — the beacon — is React and lives in the design system's tier;
12
+ * it depends on nothing here but the wire shape `POST <mount>/view {place}`.
13
+ *
14
+ * The split it depends on:
15
+ *
16
+ * metrics/ "is the fleet healthy" — throughput, latency, status codes. Promises it
17
+ * holds NO user id, in every app.
18
+ * engagement/ "is anyone using it, who, and what do they like" — per user, per app,
19
+ * per PLACE, plus a drop-off signal. User-keyed by definition, which is why
20
+ * it is a different store (`engagement.sqlite`).
21
+ *
22
+ * Read `policy.ts` before changing anything: the three rules (allowlisted places, apps
23
+ * excluded by name, counts-and-clocks only) are structural, not conventions.
24
+ *
25
+ * ── 🔴 Mount order: AFTER the session gate (`docs/engagement.md`) ─────────────
26
+ * The recorder has no credential of its own; the user key comes ONLY from the app's
27
+ * `resolveUser(c)`, which needs the gate to have run. That is the mirror image of a sync
28
+ * receiver, which carries its own bearer and must mount BEFORE the gate.
29
+ *
30
+ * ── 🔴 What stays in the app ────────────────────────────────────────────────
31
+ * `routes.manifest.json` and the test that proves it parses. `engagementPlacesFor` returns
32
+ * `[]` for anything it cannot read — silently, by design, so telemetry never takes a boot
33
+ * down — which degrades the allowlist to nothing while the board goes on rendering. Only the
34
+ * app can prove its own manifest parses; `apps/roms/routes.manifest.test.ts` is the pattern.
35
+ *
36
+ * ```ts
37
+ * import { createEngagementRecorder, EngagementStore, engagementPlacesFor, readEngagementEnv }
38
+ * from "cursedbelt-server/engagement";
39
+ *
40
+ * const setting = readEngagementEnv("myapp"); // beside the app's own data, or off
41
+ * if (setting.enabled && setting.dbPath) {
42
+ * const store = new EngagementStore({ dbPath: setting.dbPath });
43
+ * app.route("/api/engagement", createEngagementRecorder({
44
+ * store, app: "myapp", places: engagementPlacesFor(import.meta.dir),
45
+ * resolveUser: (c) => c.get("user")?.id ?? null,
46
+ * }));
47
+ * }
48
+ * ```
49
+ */
50
+ export { createEngagementRecorder, readAppEngagement, } from "./api.js";
51
+ export { readEngagementEnv } from "./env.js";
52
+ export { engagementPlacesFor } from "./places.js";
53
+ export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, resolvePlace, SESSION_GAP_MS, } from "./policy.js";
54
+ export { ENGAGEMENT_DAY_RETENTION_DAYS, EngagementStore, } from "./store.js";
55
+ export { returnCurve, summarize, } from "./summary.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One declared page: the id the board shows, and the URL shape that reaches it.
3
+ *
4
+ * Both halves are needed because the browser knows a LOCATION and the board
5
+ * wants a NAME. Carrying the path here is what lets an app adopt the beacon
6
+ * without writing a second `route.view → place id` mapping per app — twelve
7
+ * such mappings would be twelve places for the two to drift, and a drifted one
8
+ * reads as a page nobody opens.
9
+ */
10
+ export interface EngagementPlace {
11
+ id: string;
12
+ /** As the manifest states it — `/`, `/#/calendar`, `/#/t/:shelf`. */
13
+ path: string;
14
+ }
15
+ /**
16
+ * The declared places in `<dir>/routes.manifest.json`.
17
+ *
18
+ * `dir` is the app's own directory — pass `import.meta.dir` from `server.ts`,
19
+ * which is the source dir in dev and the artifact dir in production, and the
20
+ * manifest is copied into both.
21
+ */
22
+ export declare function engagementPlacesFor(dir: string): EngagementPlace[];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * An app's place allowlist, read from the `routes.manifest.json` it already
3
+ * maintains.
4
+ *
5
+ * ── Why the manifest and not a second list ─────────────────────────────────
6
+ * `routeManifest.ts` makes the argument already and it applies twice over
7
+ * here: "a hand-written list drifts, and its drift is SILENT in the direction
8
+ * that matters". For the route-health check the missing route is the untested
9
+ * one; for engagement the missing route is the page that reads as **unused**,
10
+ * which is worse — it is not a gap in the board, it is a wrong answer on it,
11
+ * and the wrong answer is one somebody deletes a feature over.
12
+ *
13
+ * The manifest is also already derived from whatever the app renders its
14
+ * navigation from, and it is already copied into the artifact by
15
+ * `buildSatelliteArtifact` — so this resolves on a checkout-less prod box as
16
+ * well as in a dev shell, with no new file to ship.
17
+ *
18
+ * ── Missing is EMPTY, never a throw ────────────────────────────────────────
19
+ * An app whose manifest cannot be found records everything as `other`, which
20
+ * is safe and visibly useless — the board shows one bucket and the owner asks
21
+ * why. A throw here would take the app's boot with it, and telemetry must
22
+ * never be able to do that (the same contract every installer in `server.ts`
23
+ * keeps).
24
+ */
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ /** Where the manifest sits, in a source tree and in a built artifact alike. */
28
+ const MANIFEST = "routes.manifest.json";
29
+ /**
30
+ * The declared places in `<dir>/routes.manifest.json`.
31
+ *
32
+ * `dir` is the app's own directory — pass `import.meta.dir` from `server.ts`,
33
+ * which is the source dir in dev and the artifact dir in production, and the
34
+ * manifest is copied into both.
35
+ */
36
+ export function engagementPlacesFor(dir) {
37
+ const path = join(dir, MANIFEST);
38
+ if (!existsSync(path))
39
+ return [];
40
+ try {
41
+ const doc = JSON.parse(readFileSync(path, "utf8"));
42
+ const routes = Array.isArray(doc)
43
+ ? doc
44
+ : doc.routes;
45
+ if (!Array.isArray(routes))
46
+ return [];
47
+ const seen = new Set();
48
+ const places = [];
49
+ for (const raw of routes) {
50
+ const r = raw;
51
+ if (typeof r.id !== "string" || !r.id || seen.has(r.id))
52
+ continue;
53
+ seen.add(r.id);
54
+ places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
55
+ }
56
+ // Sorted so the allowlist a board renders is stable between deployments.
57
+ places.sort((a, b) => a.id.localeCompare(b.id));
58
+ return places;
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The rules that decide what a place-view is allowed to say — pure, so every
3
+ * one of them is a test rather than a comment.
4
+ *
5
+ * ── Why product analytics is a SEPARATE thing from `metrics/` ───────────────
6
+ * `metrics/` answers "is the fleet healthy": throughput, latency, status codes,
7
+ * 5xx, memory. Its store header makes a promise in capital letters — no user
8
+ * id, no query string, no path — and that promise is what makes it safe to run
9
+ * the same middleware inside the zero-knowledge vault and the owner's notes.
10
+ *
11
+ * This module answers a different question the owner actually asked: "who uses
12
+ * what apps, popularity stats, which users like what features/apps, and what is
13
+ * holding and not holding interest". That question is USER-KEYED by definition,
14
+ * so it cannot live behind that promise — and weakening the promise to make
15
+ * room for it would quietly re-point twelve apps' request log at a user id.
16
+ * Two stores, two guarantees, one of which stays absolute.
17
+ *
18
+ * ── The privacy design, in three rules ──────────────────────────────────────
19
+ * 1. **A place is an ALLOWLISTED id, never a path.** The allowlist is the app's
20
+ * own `routes.manifest.json` — the list it already maintains for the route
21
+ * health check. Anything not on it records as `OTHER_PLACE`. So a note id, a
22
+ * ROM title, a vault entry name or a search string cannot become a place
23
+ * even if a caller tries: there is no code path from request text to a
24
+ * stored string. This is structural, not a sanitizer that can be outgrown.
25
+ * 2. **Some apps are excluded by NAME, in the kit.** `apps/vault` is
26
+ * zero-knowledge by construction and `fractals`/`patterns` are public with
27
+ * no accounts at all. Making that a per-app config would mean the vault's
28
+ * protection was "somebody remembered"; making it a refusal here means an
29
+ * agent who wires the beacon into the vault gets a no-op and a red gate.
30
+ * 3. **Counts and clocks only.** A row is (user, place, day) → views, active
31
+ * ms, sessions, first/last. There is no event log, so there is nothing to
32
+ * correlate against and nothing to leak beyond "this account opened this
33
+ * page N times that day".
34
+ *
35
+ * ── Honest at n = 3 ─────────────────────────────────────────────────────────
36
+ * This fleet has one real user plus family. Every number here is one a human
37
+ * can check by hand: whole counts, whole days, an explicit "never opened", and
38
+ * a drop-off signal stated in days rather than as a rate. Nothing is a ratio
39
+ * over a denominator of two.
40
+ */
41
+ /** The bucket a view lands in when its place is not on the app's allowlist. */
42
+ export declare const OTHER_PLACE = "other";
43
+ /**
44
+ * Apps that must never record engagement, whatever they ask for.
45
+ *
46
+ * 🔴 Do not turn this into a config option. See rule 2 in the file header: the
47
+ * point is that the vault's exclusion cannot be forgotten, mis-set, or lost in
48
+ * a merge. `scripts/engagementRoster.test.ts` fails if one of these ever mounts
49
+ * the recorder.
50
+ */
51
+ export declare const ENGAGEMENT_EXCLUDED_APPS: readonly string[];
52
+ /** Is this app allowed to record engagement at all? */
53
+ export declare function engagementAllowedForApp(app: string): boolean;
54
+ /** One declared page. Mirrors `places.ts`'s `EngagementPlace`. */
55
+ export interface PlaceRef {
56
+ id: string;
57
+ /** As the route manifest states it — `/`, `/#/calendar`, `/#/t/:shelf`. */
58
+ path: string;
59
+ }
60
+ /**
61
+ * Resolve what a browser reported to one of the app's declared places.
62
+ *
63
+ * A caller may send either the place ID (an app that knows its own route
64
+ * names) or the LOCATION it is at (`#/calendar`, `/#/t/scifi`) — and the
65
+ * location form is the one that matters, because it is what lets twelve apps
66
+ * adopt the beacon without each writing a `route.view → place id` mapping that
67
+ * can drift. Everything unrecognized collapses to {@link OTHER_PLACE}.
68
+ *
69
+ * 🔴 A `:param` segment matches ANY single segment and contributes NOTHING to
70
+ * what is stored: `/#/t/my-divorce-reading-list` resolves to the id `tracker`,
71
+ * and the shelf name is discarded here, before any database sees it. That is
72
+ * the mechanism behind rule 1 in the file header — the parameterized routes are
73
+ * exactly the ones whose URLs carry user content, so they are exactly the ones
74
+ * that must reduce to a name.
75
+ */
76
+ export declare function resolvePlace(claimed: string, allowed: readonly (string | PlaceRef)[]): string;
77
+ /**
78
+ * `#/deep/Link` → `deep/link`. Exported for the tests that pin rule 1.
79
+ *
80
+ * The leading `/#` a manifest path carries and the bare `#` a browser reports
81
+ * are both stripped, because `/#/calendar`, `#/calendar` and `/calendar` are
82
+ * one page and three spellings — and three spellings would read as three
83
+ * unpopular features rather than one popular one.
84
+ */
85
+ export declare function normalizePlaceId(raw: string): string;
86
+ /**
87
+ * How long a gap between two views before the second one starts a new session.
88
+ *
89
+ * 30 minutes is the web-analytics convention and it is the right one here for a
90
+ * reason specific to this fleet: the owner leaves tabs open for days. Without a
91
+ * gap rule, "session length" would be measured in hours of an idle laptop and
92
+ * every engagement number would be fiction.
93
+ */
94
+ export declare const SESSION_GAP_MS: number;
95
+ /**
96
+ * The largest slice of time a single view may contribute to `activeMs`.
97
+ *
98
+ * Dwell is measured BETWEEN beacons, so the last view before someone walks away
99
+ * has no successor to bound it — and the next beacon, whenever it comes, would
100
+ * otherwise donate the whole absence to the previous page. Capping each slice
101
+ * means an abandoned tab contributes one cap's worth and stops, which
102
+ * under-reports a long genuine read and cannot over-report an empty room. That
103
+ * is the correct direction for a signal whose job is to say what holds
104
+ * interest: over-reporting attention nobody paid is the failure that matters.
105
+ */
106
+ export declare const MAX_DWELL_SLICE_MS: number;
107
+ /** What one view adds to the running totals. */
108
+ export interface ViewFold {
109
+ /** Does this view begin a new session? */
110
+ newSession: boolean;
111
+ /** Milliseconds of attention to credit to the PREVIOUS place. */
112
+ activeMs: number;
113
+ }
114
+ /**
115
+ * Fold one view into a user's history.
116
+ *
117
+ * `lastTs` is the previous view's timestamp for this USER (across places, not
118
+ * per place) — dwell is time spent on the page you were already on, so the
119
+ * credit goes backwards. A null `lastTs` is a first-ever view: a new session
120
+ * that credits nothing, because there is no earlier page to have been reading.
121
+ *
122
+ * Clock skew is handled by clamping rather than by trusting: a `ts` at or
123
+ * before `lastTs` yields zero dwell. A device with a wrong clock should make a
124
+ * number boring, never negative.
125
+ */
126
+ export declare function foldView(lastTs: number | null, ts: number): ViewFold;
127
+ /** `2026-08-14` for a timestamp, in UTC. The day bucket every row is keyed on. */
128
+ export declare function dayKey(ts: number): string;
129
+ /** How many whole days ago `ts` was, relative to `now`. */
130
+ export declare function daysSince(ts: number, now: number): number;
131
+ /**
132
+ * How engaged an account is with an app right now.
133
+ *
134
+ * The owner asked for "what is holding and not holding interest", and half of
135
+ * that is the half that is easy to forget: an app nobody opens produces no
136
+ * rows, so it is invisible to every panel that draws what it finds. A verdict
137
+ * per (user, app) makes the absence a value.
138
+ *
139
+ * The thresholds are days, not rates, because at n = 3 a rate is noise. They
140
+ * are also deliberately generous — a fortnight away from the film library in
141
+ * August is not a lapsed user.
142
+ */
143
+ export type InterestVerdict =
144
+ /** Seen in the last week. */
145
+ "active"
146
+ /** Seen in the last month, but not this week — cooling off. */
147
+ | "fading"
148
+ /** Not seen in over a month, having used it more than once. Dropped off. */
149
+ | "dropped"
150
+ /** Opened once, ever, and never came back. The clearest "did not hold". */
151
+ | "bounced"
152
+ /** This account has never opened this app at all. */
153
+ | "never";
154
+ export declare const INTEREST_ACTIVE_DAYS = 7;
155
+ export declare const INTEREST_FADING_DAYS = 30;
156
+ /**
157
+ * Classify one (user, app) pair.
158
+ *
159
+ * `sessions` distinguishes the two ways of not coming back, and they mean
160
+ * different things: someone who used an app for a month and stopped
161
+ * (`dropped`) is a feature that stopped delivering, where someone who opened it
162
+ * once and never returned (`bounced`) is a first impression that failed. One
163
+ * bucket for both would hide whichever is rarer.
164
+ */
165
+ export declare function classifyInterest(input: {
166
+ lastSeenTs: number | null;
167
+ sessions: number;
168
+ }, now: number): InterestVerdict;