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.
- package/dist/server/activity/index.d.ts +2 -1
- package/dist/server/activity/index.js +2 -1
- package/dist/server/auth/passwordCost.d.ts +21 -0
- package/dist/server/auth/passwordCost.js +80 -0
- package/dist/server/bench/index.d.ts +1 -0
- package/dist/server/bench/index.js +1 -0
- package/dist/server/bench/tail.d.ts +110 -0
- package/dist/server/bench/tail.js +182 -0
- package/dist/server/d1/index.d.ts +1 -2
- package/dist/server/d1/index.js +9 -9
- package/dist/server/d1/pullD1.js +16 -3
- package/dist/server/engagement/api.d.ts +71 -0
- package/dist/server/engagement/api.js +84 -0
- package/dist/server/engagement/env.d.ts +18 -0
- package/dist/server/engagement/env.js +52 -0
- package/dist/server/engagement/index.d.ts +55 -0
- package/dist/server/engagement/index.js +55 -0
- package/dist/server/engagement/places.d.ts +22 -0
- package/dist/server/engagement/places.js +63 -0
- package/dist/server/engagement/policy.d.ts +168 -0
- package/dist/server/engagement/policy.js +202 -0
- package/dist/server/engagement/store.d.ts +92 -0
- package/dist/server/engagement/store.js +223 -0
- package/dist/server/engagement/summary.d.ts +102 -0
- package/dist/server/engagement/summary.js +127 -0
- package/dist/server/engagement/types.d.ts +42 -0
- package/dist/server/engagement/types.js +12 -0
- package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
- package/dist/server/maps-budget/mapsBudget.js +193 -0
- package/dist/server/satellite/config.d.ts +173 -0
- package/dist/server/satellite/config.js +259 -0
- package/dist/server/satellite/door.d.ts +112 -0
- package/dist/server/satellite/door.js +149 -0
- package/dist/server/storage/binaryStore.d.ts +18 -0
- package/dist/server/storage/binaryStore.js +32 -1
- package/dist/server/storage/derivatives.d.ts +253 -0
- package/dist/server/storage/derivatives.js +266 -0
- package/dist/server/storage/uploadSession.d.ts +75 -0
- package/dist/server/storage/uploadSession.js +74 -0
- package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
- package/docs/activity.md +43 -0
- package/docs/engagement.md +47 -0
- package/docs/notifications.md +43 -0
- package/docs/retention.md +81 -0
- package/docs/skipped-tests.md +19 -0
- package/package.json +46 -9
- package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
- package/src/leafSubpathsImportNothing.spec.ts +49 -0
- package/src/server/activity/index.ts +2 -1
- package/src/server/auth/passwordCost.spec.ts +42 -0
- package/src/server/auth/passwordCost.ts +87 -0
- package/src/server/bench/index.ts +13 -0
- package/src/server/bench/tail.spec.ts +126 -0
- package/src/server/bench/tail.ts +237 -0
- package/src/server/d1/index.ts +9 -9
- package/src/server/d1/pullD1.spec.ts +20 -0
- package/src/server/d1/pullD1.ts +18 -2
- package/src/server/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- 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;
|