cursedbelt-server 4.18.1 → 4.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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,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;
|
|
@@ -0,0 +1,202 @@
|
|
|
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 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 const ENGAGEMENT_EXCLUDED_APPS = [
|
|
52
|
+
// Zero-knowledge by construction. The server stores opaque ciphertext and
|
|
53
|
+
// must not learn even which pages the owner spends time on — a per-place
|
|
54
|
+
// dwell profile over a password manager is a map of which credentials
|
|
55
|
+
// matter, which is exactly the thing the design refuses to hold.
|
|
56
|
+
"vault",
|
|
57
|
+
// Public, no accounts, no session — there is no user to key a row on, and
|
|
58
|
+
// tsk_01KZN61WJ8X15SKGPMFABBJ889 exists because an analytics beacon reached
|
|
59
|
+
// somewhere it had no business being. A public app gets request metrics.
|
|
60
|
+
"fractals",
|
|
61
|
+
"patterns",
|
|
62
|
+
// The dev-only host-header proxy. Never deployed, serves no pages of its own.
|
|
63
|
+
"gateway",
|
|
64
|
+
];
|
|
65
|
+
/** Is this app allowed to record engagement at all? */
|
|
66
|
+
export function engagementAllowedForApp(app) {
|
|
67
|
+
return !ENGAGEMENT_EXCLUDED_APPS.includes(app.trim().toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve what a browser reported to one of the app's declared places.
|
|
71
|
+
*
|
|
72
|
+
* A caller may send either the place ID (an app that knows its own route
|
|
73
|
+
* names) or the LOCATION it is at (`#/calendar`, `/#/t/scifi`) — and the
|
|
74
|
+
* location form is the one that matters, because it is what lets twelve apps
|
|
75
|
+
* adopt the beacon without each writing a `route.view → place id` mapping that
|
|
76
|
+
* can drift. Everything unrecognized collapses to {@link OTHER_PLACE}.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 A `:param` segment matches ANY single segment and contributes NOTHING to
|
|
79
|
+
* what is stored: `/#/t/my-divorce-reading-list` resolves to the id `tracker`,
|
|
80
|
+
* and the shelf name is discarded here, before any database sees it. That is
|
|
81
|
+
* the mechanism behind rule 1 in the file header — the parameterized routes are
|
|
82
|
+
* exactly the ones whose URLs carry user content, so they are exactly the ones
|
|
83
|
+
* that must reduce to a name.
|
|
84
|
+
*/
|
|
85
|
+
export function resolvePlace(claimed, allowed) {
|
|
86
|
+
// NOT short-circuited on empty: the home page normalizes to `""` from both
|
|
87
|
+
// sides (the manifest writes it `/`, a browser reports `/` or `#/`), so the
|
|
88
|
+
// root is a legitimate match rather than a missing value. An app whose
|
|
89
|
+
// manifest declares no root still gets `other` from the loop below.
|
|
90
|
+
const want = normalizePlaceId(claimed);
|
|
91
|
+
const wantSegments = want.split("/");
|
|
92
|
+
for (const entry of allowed) {
|
|
93
|
+
const ref = typeof entry === "string" ? { id: entry, path: "" } : entry;
|
|
94
|
+
if (normalizePlaceId(ref.id) === want)
|
|
95
|
+
return ref.id;
|
|
96
|
+
if (ref.path && pathMatches(ref.path, wantSegments))
|
|
97
|
+
return ref.id;
|
|
98
|
+
}
|
|
99
|
+
return OTHER_PLACE;
|
|
100
|
+
}
|
|
101
|
+
/** Does `segments` match this manifest path, treating `:param` as a wildcard? */
|
|
102
|
+
function pathMatches(manifestPath, segments) {
|
|
103
|
+
const pattern = normalizePlaceId(manifestPath);
|
|
104
|
+
// The manifest writes the home page as `/`, which normalizes to the empty
|
|
105
|
+
// string — and so does a browser sitting at `/` or `#/`. Both are one empty
|
|
106
|
+
// segment, so the generic comparison below already agrees; this is only here
|
|
107
|
+
// to say that the empty case is intended rather than an accident.
|
|
108
|
+
const parts = pattern.split("/");
|
|
109
|
+
if (parts.length !== segments.length)
|
|
110
|
+
return false;
|
|
111
|
+
return parts.every((part, i) => part.startsWith(":") || part === segments[i]);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `#/deep/Link` → `deep/link`. Exported for the tests that pin rule 1.
|
|
115
|
+
*
|
|
116
|
+
* The leading `/#` a manifest path carries and the bare `#` a browser reports
|
|
117
|
+
* are both stripped, because `/#/calendar`, `#/calendar` and `/calendar` are
|
|
118
|
+
* one page and three spellings — and three spellings would read as three
|
|
119
|
+
* unpopular features rather than one popular one.
|
|
120
|
+
*/
|
|
121
|
+
export function normalizePlaceId(raw) {
|
|
122
|
+
return (raw
|
|
123
|
+
.trim()
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
// The query string goes FIRST and unconditionally — `#/search?q=<what
|
|
126
|
+
// they typed>` is the single most likely way user text would arrive here.
|
|
127
|
+
.replace(/\?.*$/, "")
|
|
128
|
+
.replace(/^\/?#/, "")
|
|
129
|
+
.replace(/^\/+|\/+$/g, ""));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* How long a gap between two views before the second one starts a new session.
|
|
133
|
+
*
|
|
134
|
+
* 30 minutes is the web-analytics convention and it is the right one here for a
|
|
135
|
+
* reason specific to this fleet: the owner leaves tabs open for days. Without a
|
|
136
|
+
* gap rule, "session length" would be measured in hours of an idle laptop and
|
|
137
|
+
* every engagement number would be fiction.
|
|
138
|
+
*/
|
|
139
|
+
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
140
|
+
/**
|
|
141
|
+
* The largest slice of time a single view may contribute to `activeMs`.
|
|
142
|
+
*
|
|
143
|
+
* Dwell is measured BETWEEN beacons, so the last view before someone walks away
|
|
144
|
+
* has no successor to bound it — and the next beacon, whenever it comes, would
|
|
145
|
+
* otherwise donate the whole absence to the previous page. Capping each slice
|
|
146
|
+
* means an abandoned tab contributes one cap's worth and stops, which
|
|
147
|
+
* under-reports a long genuine read and cannot over-report an empty room. That
|
|
148
|
+
* is the correct direction for a signal whose job is to say what holds
|
|
149
|
+
* interest: over-reporting attention nobody paid is the failure that matters.
|
|
150
|
+
*/
|
|
151
|
+
export const MAX_DWELL_SLICE_MS = 5 * 60 * 1000;
|
|
152
|
+
/**
|
|
153
|
+
* Fold one view into a user's history.
|
|
154
|
+
*
|
|
155
|
+
* `lastTs` is the previous view's timestamp for this USER (across places, not
|
|
156
|
+
* per place) — dwell is time spent on the page you were already on, so the
|
|
157
|
+
* credit goes backwards. A null `lastTs` is a first-ever view: a new session
|
|
158
|
+
* that credits nothing, because there is no earlier page to have been reading.
|
|
159
|
+
*
|
|
160
|
+
* Clock skew is handled by clamping rather than by trusting: a `ts` at or
|
|
161
|
+
* before `lastTs` yields zero dwell. A device with a wrong clock should make a
|
|
162
|
+
* number boring, never negative.
|
|
163
|
+
*/
|
|
164
|
+
export function foldView(lastTs, ts) {
|
|
165
|
+
if (lastTs === null)
|
|
166
|
+
return { newSession: true, activeMs: 0 };
|
|
167
|
+
const gap = ts - lastTs;
|
|
168
|
+
if (gap <= 0)
|
|
169
|
+
return { newSession: false, activeMs: 0 };
|
|
170
|
+
if (gap >= SESSION_GAP_MS)
|
|
171
|
+
return { newSession: true, activeMs: 0 };
|
|
172
|
+
return { newSession: false, activeMs: Math.min(gap, MAX_DWELL_SLICE_MS) };
|
|
173
|
+
}
|
|
174
|
+
/** `2026-08-14` for a timestamp, in UTC. The day bucket every row is keyed on. */
|
|
175
|
+
export function dayKey(ts) {
|
|
176
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
177
|
+
}
|
|
178
|
+
/** How many whole days ago `ts` was, relative to `now`. */
|
|
179
|
+
export function daysSince(ts, now) {
|
|
180
|
+
return Math.max(0, Math.floor((now - ts) / 86_400_000));
|
|
181
|
+
}
|
|
182
|
+
export const INTEREST_ACTIVE_DAYS = 7;
|
|
183
|
+
export const INTEREST_FADING_DAYS = 30;
|
|
184
|
+
/**
|
|
185
|
+
* Classify one (user, app) pair.
|
|
186
|
+
*
|
|
187
|
+
* `sessions` distinguishes the two ways of not coming back, and they mean
|
|
188
|
+
* different things: someone who used an app for a month and stopped
|
|
189
|
+
* (`dropped`) is a feature that stopped delivering, where someone who opened it
|
|
190
|
+
* once and never returned (`bounced`) is a first impression that failed. One
|
|
191
|
+
* bucket for both would hide whichever is rarer.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyInterest(input, now) {
|
|
194
|
+
if (input.lastSeenTs === null)
|
|
195
|
+
return "never";
|
|
196
|
+
const age = daysSince(input.lastSeenTs, now);
|
|
197
|
+
if (age < INTEREST_ACTIVE_DAYS)
|
|
198
|
+
return "active";
|
|
199
|
+
if (age < INTEREST_FADING_DAYS)
|
|
200
|
+
return "fading";
|
|
201
|
+
return input.sessions <= 1 ? "bounced" : "dropped";
|
|
202
|
+
}
|