cursedbelt-server 4.26.0 → 4.27.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `cursedbelt-server/engagement/d1` — the engagement recorder over D1, for an app a Worker serves.
3
+ *
4
+ * ── Why it exists ───────────────────────────────────────────────────────────
5
+ * `./engagement` is Bun-only: its store is a synchronous `bun:sqlite` file (`engagement.sqlite`)
6
+ * that station's Metrics page reads off disk. A Worker has neither, so an app that moved to one
7
+ * stopped being heard with no error anywhere — `collections` from its 2026-09-22 cutover until
8
+ * this subpath (task 2096). The SAME three tables live in the app's own D1 instead (its
9
+ * `db/schema.sql`, from {@link ENGAGEMENT_DDL}), written with the SAME fold (`./policy.ts` →
10
+ * `foldView`, `dayKey`, `resolvePlace`), and station reads them from D1 for any Worker-served app.
11
+ *
12
+ * It was `apps/music/src/server/engagementD1.ts` plus a copied `kit/engagement/policy.ts` held
13
+ * byte-identical by a parity test (task 2081). collections was the second consumer, so it was
14
+ * lifted here rather than copied again — and the policy copy went with it: this module imports
15
+ * the library's own `policy.ts`, so there is one privacy rule, not one per Worker.
16
+ *
17
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
18
+ * (`telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or `./api.js` here.
19
+ *
20
+ * 🔴 The kit's privacy story holds unchanged: the place is resolved against the app's own route
21
+ * manifest AT THE DOOR, so an id, an album name or a search string never reaches a row; the
22
+ * account is the session's subject, never anything in the body. Mount it INSIDE the session gate.
23
+ *
24
+ * 🔴 {@link ENGAGEMENT_DDL} must stay byte-identical in effect to `EngagementStore`'s `migrate()` —
25
+ * station reads both shapes with one query set. `d1.spec.ts` builds both and compares their
26
+ * `sqlite_master` rows, so a column added to one and not the other is a red.
27
+ *
28
+ * ```ts
29
+ * import { createD1EngagementRecorder, placesFromManifest } from "cursedbelt-server/engagement/d1";
30
+ * import routesManifest from "../routes.manifest.json";
31
+ *
32
+ * gated.route("/api/engagement", createD1EngagementRecorder({
33
+ * db, places: placesFromManifest(routesManifest), resolveUser: (c) => sso.userOf(c)?.id ?? null,
34
+ * }));
35
+ * ```
36
+ */
37
+ import type { D1LikeDatabase } from "../d1/types.js";
38
+ import type { Context } from "hono";
39
+ import { Hono } from "hono";
40
+ import { type PlaceRef } from "./policy.js";
41
+ /** The kit store's own tables and indexes, as one-line DDL — see the header. */
42
+ export declare const ENGAGEMENT_DDL: readonly string[];
43
+ /**
44
+ * Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
45
+ * account's previous view) and ONE batch of up to five statements, so a view is atomic and
46
+ * costs two D1 round trips however much it credits.
47
+ */
48
+ export declare function recordViewD1(db: D1LikeDatabase, input: {
49
+ userKey: string;
50
+ place: string;
51
+ ts: number;
52
+ }): Promise<{
53
+ newSession: boolean;
54
+ activeMs: number;
55
+ }>;
56
+ /**
57
+ * The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
58
+ * `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
59
+ * none on the Mac; station reads the tables directly.
60
+ */
61
+ export declare function createD1EngagementRecorder(options: {
62
+ db: D1LikeDatabase;
63
+ places: readonly PlaceRef[];
64
+ resolveUser: (c: Context) => string | null;
65
+ now?: () => number;
66
+ }): Hono;
67
+ export { placesFromManifest } from "./manifestPlaces.js";
@@ -0,0 +1,81 @@
1
+ import { Hono } from "hono";
2
+ import { dayKey, foldView, OTHER_PLACE, resolvePlace } from "./policy.js";
3
+ /** The kit store's own tables and indexes, as one-line DDL — see the header. */
4
+ export const ENGAGEMENT_DDL = [
5
+ "CREATE TABLE IF NOT EXISTS engagement_days ( user_key TEXT NOT NULL, place TEXT NOT NULL, day TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place, day) )",
6
+ "CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)",
7
+ "CREATE TABLE IF NOT EXISTS engagement_places ( user_key TEXT NOT NULL, place TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place) )",
8
+ "CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)",
9
+ "CREATE TABLE IF NOT EXISTS engagement_users ( user_key TEXT PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, last_place TEXT NOT NULL DEFAULT '' )",
10
+ ];
11
+ /**
12
+ * Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
13
+ * account's previous view) and ONE batch of up to five statements, so a view is atomic and
14
+ * costs two D1 round trips however much it credits.
15
+ */
16
+ export async function recordViewD1(db, input) {
17
+ const { userKey, place, ts } = input;
18
+ const prior = await db
19
+ .prepare("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
20
+ .bind(userKey)
21
+ .first();
22
+ const fold = foldView(prior ? prior.last_ts : null, ts);
23
+ const sessionDelta = fold.newSession ? 1 : 0;
24
+ const statements = [];
25
+ if (fold.activeMs > 0 && prior?.last_place) {
26
+ // The time since the last view is credited to the place the account was LOOKING AT.
27
+ statements.push(db
28
+ .prepare("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?")
29
+ .bind(fold.activeMs, userKey, prior.last_place, dayKey(prior.last_ts)), db
30
+ .prepare("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?")
31
+ .bind(fold.activeMs, userKey, prior.last_place));
32
+ }
33
+ statements.push(db
34
+ .prepare(`INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
35
+ VALUES (?, ?, ?, 1, 0, ?, ?, ?)
36
+ ON CONFLICT(user_key, place, day) DO UPDATE SET
37
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`)
38
+ .bind(userKey, place, dayKey(ts), sessionDelta, ts, ts), db
39
+ .prepare(`INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
40
+ VALUES (?, ?, 1, ?, 0, ?, ?)
41
+ ON CONFLICT(user_key, place) DO UPDATE SET
42
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`)
43
+ .bind(userKey, place, sessionDelta, ts, ts), db
44
+ .prepare(`INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
45
+ VALUES (?, 1, ?, 0, ?, ?, ?)
46
+ ON CONFLICT(user_key) DO UPDATE SET
47
+ views = views + 1, sessions = sessions + excluded.sessions, active_ms = active_ms + ?,
48
+ last_ts = excluded.last_ts, last_place = excluded.last_place`)
49
+ .bind(userKey, sessionDelta, ts, ts, place, fold.activeMs));
50
+ await db.batch(statements);
51
+ return fold;
52
+ }
53
+ /**
54
+ * The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
55
+ * `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
56
+ * none on the Mac; station reads the tables directly.
57
+ */
58
+ export function createD1EngagementRecorder(options) {
59
+ const app = new Hono();
60
+ const now = options.now ?? (() => Date.now());
61
+ app.post("/view", async (c) => {
62
+ const userKey = options.resolveUser(c);
63
+ if (!userKey)
64
+ return c.json({ error: "unauthenticated" }, 401);
65
+ let body;
66
+ try {
67
+ body = await c.req.json();
68
+ }
69
+ catch {
70
+ return c.json({ error: "expected a json body" }, 400);
71
+ }
72
+ const claimed = body?.place;
73
+ if (typeof claimed !== "string")
74
+ return c.json({ error: "place must be a string" }, 400);
75
+ const place = resolvePlace(claimed, options.places);
76
+ const fold = await recordViewD1(options.db, { userKey, place, ts: now() });
77
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
78
+ });
79
+ return app;
80
+ }
81
+ export { placesFromManifest } from "./manifestPlaces.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A parsed `routes.manifest.json` → the place allowlist. Pure, and Worker-safe: `places.ts`
3
+ * reads the file off disk for a Bun server, and a Worker hands over the JSON it bundles
4
+ * (`./d1.ts`). ONE rule for both — until task 2096 music's Worker carried its own copy of it.
5
+ *
6
+ * Anything it cannot read is `[]`, never a throw: see `places.ts`, "Missing is EMPTY".
7
+ */
8
+ import type { PlaceRef } from "./policy.js";
9
+ export declare function placesFromManifest(doc: unknown): PlaceRef[];
@@ -0,0 +1,17 @@
1
+ export function placesFromManifest(doc) {
2
+ const routes = Array.isArray(doc) ? doc : doc?.routes;
3
+ if (!Array.isArray(routes))
4
+ return [];
5
+ const seen = new Set();
6
+ const places = [];
7
+ for (const raw of routes) {
8
+ const r = raw;
9
+ if (!r || typeof r.id !== "string" || !r.id || seen.has(r.id))
10
+ continue;
11
+ seen.add(r.id);
12
+ places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
13
+ }
14
+ // Sorted so the allowlist a board renders is stable between deployments.
15
+ places.sort((a, b) => a.id.localeCompare(b.id));
16
+ return places;
17
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
+ import { placesFromManifest } from "./manifestPlaces.js";
27
28
  /** Where the manifest sits, in a source tree and in a built artifact alike. */
28
29
  const MANIFEST = "routes.manifest.json";
29
30
  /**
@@ -38,24 +39,7 @@ export function engagementPlacesFor(dir) {
38
39
  if (!existsSync(path))
39
40
  return [];
40
41
  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;
42
+ return placesFromManifest(JSON.parse(readFileSync(path, "utf8")));
59
43
  }
60
44
  catch {
61
45
  return [];
@@ -257,6 +257,16 @@ export declare class MasterLock {
257
257
  * `req` is optional only so a health check can ask without one. Pass it wherever there
258
258
  * IS a request: without it the reply carries no account, and a client would read that
259
259
  * as "locked" while holding a perfectly good unlock.
260
+ *
261
+ * 🔴 With a request, `locked` is THIS CALLER's answer — the one {@link accountFor} gives —
262
+ * and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
263
+ * open"), which the lock page's own script takes as "you are in" and answers with
264
+ * `location.reload()`; the guard, asking the per-caller question, serves the lock page
265
+ * again, and the page spins for as long as any other browser stays unlocked — no password
266
+ * box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
267
+ * context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
268
+ * `"locked": false` with no cookie at all. Without a request it stays the site-wide
269
+ * health reading, which is all a caller with no request can mean.
260
270
  */
261
271
  status(req?: Request): MasterLockStatus;
262
272
  /**
@@ -191,6 +191,16 @@ export class MasterLock {
191
191
  * `req` is optional only so a health check can ask without one. Pass it wherever there
192
192
  * IS a request: without it the reply carries no account, and a client would read that
193
193
  * as "locked" while holding a perfectly good unlock.
194
+ *
195
+ * 🔴 With a request, `locked` is THIS CALLER's answer — the one {@link accountFor} gives —
196
+ * and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
197
+ * open"), which the lock page's own script takes as "you are in" and answers with
198
+ * `location.reload()`; the guard, asking the per-caller question, serves the lock page
199
+ * again, and the page spins for as long as any other browser stays unlocked — no password
200
+ * box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
201
+ * context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
202
+ * `"locked": false` with no cookie at all. Without a request it stays the site-wide
203
+ * health reading, which is all a caller with no request can mean.
194
204
  */
195
205
  status(req) {
196
206
  const accountId = req ? this.accountFor(req) : null;
@@ -198,7 +208,7 @@ export class MasterLock {
198
208
  return {
199
209
  configured: this.configured,
200
210
  enrollable: this.awaitingEnrollment,
201
- locked: !this.unlocked,
211
+ locked: req ? accountId === null : !this.unlocked,
202
212
  kdf: this.kdf,
203
213
  idleMs: this.idleMs,
204
214
  remainingMs: this.remainingMs,
@@ -44,4 +44,10 @@ manifest parses (`apps/roms/routes.manifest.test.ts` is the pattern).
44
44
  excluded app, `SATELLITE_ENGAGEMENT=0` or an unresolvable data directory each turn recording OFF
45
45
  and say why, and the app still boots.
46
46
 
47
+ **On a Worker**, the same three tables live in the app's own D1 instead:
48
+ `cursedbelt-server/engagement/d1` (since 4.27.0, task 2096) exports `ENGAGEMENT_DDL` for the
49
+ app's `db/schema.sql`, `createD1EngagementRecorder` to mount inside the gate, and
50
+ `placesFromManifest` for the bundled `routes.manifest.json`. It is Worker-safe and uses the same
51
+ `policy.ts`, so there is one privacy rule whichever host records. station reads either shape.
52
+
47
53
  Cite this page from an app as `cursedbelt-server/docs/engagement.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.26.0",
3
+ "version": "4.27.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -125,6 +125,12 @@
125
125
  "source": "./src/server/engagement/index.ts",
126
126
  "import": "./dist/server/_bunOnly.js"
127
127
  },
128
+ "./engagement/d1": {
129
+ "types": "./dist/server/engagement/d1.d.ts",
130
+ "bun": "./src/server/engagement/d1.ts",
131
+ "source": "./src/server/engagement/d1.ts",
132
+ "import": "./dist/server/engagement/d1.js"
133
+ },
128
134
  "./errors": {
129
135
  "types": "./dist/server/errors.d.ts",
130
136
  "bun": "./src/server/errors.ts",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The Worker's engagement recorder is the kit's, over D1 — the SAME tables and the SAME rows for
3
+ * the same views, because station reads both with one query set (tasks 2081, 2096).
4
+ */
5
+ import { Database } from 'bun:sqlite';
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { Hono } from 'hono';
11
+ import { createLocalD1 } from '../d1/local.js';
12
+ import { createD1EngagementRecorder, ENGAGEMENT_DDL, placesFromManifest, recordViewD1 } from './d1.js';
13
+ import { engagementPlacesFor } from './places.js';
14
+ import { EngagementStore } from './store.js';
15
+
16
+ const dirs: string[] = [];
17
+ afterEach(() => {
18
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
19
+ });
20
+ const tempDir = (): string => {
21
+ const dir = mkdtempSync(join(tmpdir(), 'engagement-d1-'));
22
+ dirs.push(dir);
23
+ return dir;
24
+ };
25
+ const kitStore = (): EngagementStore => new EngagementStore({ dbPath: join(tempDir(), 'engagement.sqlite') });
26
+ const d1 = () => {
27
+ const sqlite = new Database(':memory:');
28
+ for (const ddl of ENGAGEMENT_DDL) sqlite.run(ddl);
29
+ return { sqlite, db: createLocalD1(sqlite) };
30
+ };
31
+ const shape = (db: Database) =>
32
+ (
33
+ db
34
+ .query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%engagement%' AND sql IS NOT NULL ORDER BY name")
35
+ .all() as { type: string; name: string; sql: string }[]
36
+ ).map((r) => ({ type: r.type, name: r.name, sql: r.sql.replace(/\s+/g, ' ').replace('IF NOT EXISTS ', '').trim() }));
37
+ const dump = (db: Database) => ({
38
+ days: db.query('SELECT * FROM engagement_days ORDER BY user_key, place, day').all(),
39
+ places: db.query('SELECT * FROM engagement_places ORDER BY user_key, place').all(),
40
+ users: db.query('SELECT * FROM engagement_users ORDER BY user_key').all(),
41
+ });
42
+
43
+ describe('cursedbelt-server/engagement/d1', () => {
44
+ test("🔴 creates exactly the kit store's tables and indexes — station reads both with one query set", () => {
45
+ const kit = kitStore();
46
+ const { sqlite } = d1();
47
+ expect(shape(sqlite)).toEqual(shape(kit.handle));
48
+ });
49
+
50
+ test("🔴 the same views produce the same rows as the kit's store — sessions, dwell, days", async () => {
51
+ const kit = kitStore();
52
+ const { sqlite, db } = d1();
53
+ const t0 = Date.UTC(2026, 8, 23, 10, 0, 0);
54
+ const views = [
55
+ { userKey: 'u1', place: 'library', ts: t0 },
56
+ { userKey: 'u1', place: 'downloads', ts: t0 + 40_000 },
57
+ { userKey: 'u2', place: 'library', ts: t0 + 50_000 },
58
+ { userKey: 'u1', place: 'library', ts: t0 + 90_000 },
59
+ // A new session (a long gap) on the next day.
60
+ { userKey: 'u1', place: 'library', ts: t0 + 26 * 3_600_000 },
61
+ ];
62
+ for (const v of views) expect(await recordViewD1(db, v)).toEqual(kit.recordView(v));
63
+ expect(dump(sqlite)).toEqual(dump(kit.handle));
64
+ });
65
+
66
+ test('records only a signed-in account, and buckets an off-manifest place as `other`', async () => {
67
+ const { sqlite, db } = d1();
68
+ const places = placesFromManifest({ routes: [{ id: 'library', path: '/library' }, { id: 'library' }, { path: '/x' }] });
69
+ expect(places).toEqual([{ id: 'library', path: '/library' }]);
70
+ let user: string | null = null;
71
+ const app = new Hono().route('/api/engagement', createD1EngagementRecorder({ db, places, resolveUser: () => user }));
72
+ const post = (place: unknown) =>
73
+ app.request('http://x/api/engagement/view', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ place }),
77
+ });
78
+ expect((await post('library')).status).toBe(401);
79
+ user = 'u1';
80
+ expect(await (await post('library')).json()).toMatchObject({ ok: true, place: 'library', bucketed: false });
81
+ // An id, an album name, a search string — never a row of its own.
82
+ expect(await (await post('album:Some Private Name')).json()).toMatchObject({ place: 'other', bucketed: true });
83
+ expect((await post(42)).status).toBe(400);
84
+ expect(sqlite.query('SELECT place FROM engagement_places ORDER BY place').all()).toEqual([
85
+ { place: 'library' },
86
+ { place: 'other' },
87
+ ]);
88
+ });
89
+
90
+ test('🔴 ONE manifest rule: a Worker handed the JSON and a Bun server reading the file agree', async () => {
91
+ const doc = { routes: [{ id: 'queue', path: '/#/queue' }, { id: 'home', path: '/' }, { id: 'queue' }, { id: '' }, null] };
92
+ const dir = tempDir();
93
+ await Bun.write(join(dir, 'routes.manifest.json'), JSON.stringify(doc));
94
+ expect(placesFromManifest(doc)).toEqual([
95
+ { id: 'home', path: '/' },
96
+ { id: 'queue', path: '/#/queue' },
97
+ ]);
98
+ expect(engagementPlacesFor(dir)).toEqual(placesFromManifest(doc));
99
+ expect(placesFromManifest('not a manifest')).toEqual([]);
100
+ expect(placesFromManifest(null)).toEqual([]);
101
+ });
102
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `cursedbelt-server/engagement/d1` — the engagement recorder over D1, for an app a Worker serves.
3
+ *
4
+ * ── Why it exists ───────────────────────────────────────────────────────────
5
+ * `./engagement` is Bun-only: its store is a synchronous `bun:sqlite` file (`engagement.sqlite`)
6
+ * that station's Metrics page reads off disk. A Worker has neither, so an app that moved to one
7
+ * stopped being heard with no error anywhere — `collections` from its 2026-09-22 cutover until
8
+ * this subpath (task 2096). The SAME three tables live in the app's own D1 instead (its
9
+ * `db/schema.sql`, from {@link ENGAGEMENT_DDL}), written with the SAME fold (`./policy.ts` →
10
+ * `foldView`, `dayKey`, `resolvePlace`), and station reads them from D1 for any Worker-served app.
11
+ *
12
+ * It was `apps/music/src/server/engagementD1.ts` plus a copied `kit/engagement/policy.ts` held
13
+ * byte-identical by a parity test (task 2081). collections was the second consumer, so it was
14
+ * lifted here rather than copied again — and the policy copy went with it: this module imports
15
+ * the library's own `policy.ts`, so there is one privacy rule, not one per Worker.
16
+ *
17
+ * 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
18
+ * (`telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or `./api.js` here.
19
+ *
20
+ * 🔴 The kit's privacy story holds unchanged: the place is resolved against the app's own route
21
+ * manifest AT THE DOOR, so an id, an album name or a search string never reaches a row; the
22
+ * account is the session's subject, never anything in the body. Mount it INSIDE the session gate.
23
+ *
24
+ * 🔴 {@link ENGAGEMENT_DDL} must stay byte-identical in effect to `EngagementStore`'s `migrate()` —
25
+ * station reads both shapes with one query set. `d1.spec.ts` builds both and compares their
26
+ * `sqlite_master` rows, so a column added to one and not the other is a red.
27
+ *
28
+ * ```ts
29
+ * import { createD1EngagementRecorder, placesFromManifest } from "cursedbelt-server/engagement/d1";
30
+ * import routesManifest from "../routes.manifest.json";
31
+ *
32
+ * gated.route("/api/engagement", createD1EngagementRecorder({
33
+ * db, places: placesFromManifest(routesManifest), resolveUser: (c) => sso.userOf(c)?.id ?? null,
34
+ * }));
35
+ * ```
36
+ */
37
+ import type { D1LikeDatabase } from "../d1/types.js";
38
+ import type { Context } from "hono";
39
+ import { Hono } from "hono";
40
+ import { dayKey, foldView, OTHER_PLACE, type PlaceRef, resolvePlace } from "./policy.js";
41
+
42
+ /** The kit store's own tables and indexes, as one-line DDL — see the header. */
43
+ export const ENGAGEMENT_DDL: readonly string[] = [
44
+ "CREATE TABLE IF NOT EXISTS engagement_days ( user_key TEXT NOT NULL, place TEXT NOT NULL, day TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place, day) )",
45
+ "CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)",
46
+ "CREATE TABLE IF NOT EXISTS engagement_places ( user_key TEXT NOT NULL, place TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place) )",
47
+ "CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)",
48
+ "CREATE TABLE IF NOT EXISTS engagement_users ( user_key TEXT PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, last_place TEXT NOT NULL DEFAULT '' )",
49
+ ];
50
+
51
+ /**
52
+ * Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
53
+ * account's previous view) and ONE batch of up to five statements, so a view is atomic and
54
+ * costs two D1 round trips however much it credits.
55
+ */
56
+ export async function recordViewD1(
57
+ db: D1LikeDatabase,
58
+ input: { userKey: string; place: string; ts: number },
59
+ ): Promise<{ newSession: boolean; activeMs: number }> {
60
+ const { userKey, place, ts } = input;
61
+ const prior = await db
62
+ .prepare("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
63
+ .bind(userKey)
64
+ .first<{ last_ts: number; last_place: string }>();
65
+ const fold = foldView(prior ? prior.last_ts : null, ts);
66
+ const sessionDelta = fold.newSession ? 1 : 0;
67
+ const statements = [];
68
+ if (fold.activeMs > 0 && prior?.last_place) {
69
+ // The time since the last view is credited to the place the account was LOOKING AT.
70
+ statements.push(
71
+ db
72
+ .prepare("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?")
73
+ .bind(fold.activeMs, userKey, prior.last_place, dayKey(prior.last_ts)),
74
+ db
75
+ .prepare("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?")
76
+ .bind(fold.activeMs, userKey, prior.last_place),
77
+ );
78
+ }
79
+ statements.push(
80
+ db
81
+ .prepare(
82
+ `INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
83
+ VALUES (?, ?, ?, 1, 0, ?, ?, ?)
84
+ ON CONFLICT(user_key, place, day) DO UPDATE SET
85
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
86
+ )
87
+ .bind(userKey, place, dayKey(ts), sessionDelta, ts, ts),
88
+ db
89
+ .prepare(
90
+ `INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
91
+ VALUES (?, ?, 1, ?, 0, ?, ?)
92
+ ON CONFLICT(user_key, place) DO UPDATE SET
93
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
94
+ )
95
+ .bind(userKey, place, sessionDelta, ts, ts),
96
+ db
97
+ .prepare(
98
+ `INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
99
+ VALUES (?, 1, ?, 0, ?, ?, ?)
100
+ ON CONFLICT(user_key) DO UPDATE SET
101
+ views = views + 1, sessions = sessions + excluded.sessions, active_ms = active_ms + ?,
102
+ last_ts = excluded.last_ts, last_place = excluded.last_place`,
103
+ )
104
+ .bind(userKey, sessionDelta, ts, ts, place, fold.activeMs),
105
+ );
106
+ await db.batch(statements);
107
+ return fold;
108
+ }
109
+
110
+ /**
111
+ * The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
112
+ * `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
113
+ * none on the Mac; station reads the tables directly.
114
+ */
115
+ export function createD1EngagementRecorder(options: {
116
+ db: D1LikeDatabase;
117
+ places: readonly PlaceRef[];
118
+ resolveUser: (c: Context) => string | null;
119
+ now?: () => number;
120
+ }): Hono {
121
+ const app = new Hono();
122
+ const now = options.now ?? (() => Date.now());
123
+ app.post("/view", async (c) => {
124
+ const userKey = options.resolveUser(c);
125
+ if (!userKey) return c.json({ error: "unauthenticated" }, 401);
126
+ let body: unknown;
127
+ try {
128
+ body = await c.req.json();
129
+ } catch {
130
+ return c.json({ error: "expected a json body" }, 400);
131
+ }
132
+ const claimed = (body as { place?: unknown } | null)?.place;
133
+ if (typeof claimed !== "string") return c.json({ error: "place must be a string" }, 400);
134
+ const place = resolvePlace(claimed, options.places);
135
+ const fold = await recordViewD1(options.db, { userKey, place, ts: now() });
136
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
137
+ });
138
+ return app;
139
+ }
140
+
141
+ export { placesFromManifest } from "./manifestPlaces.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A parsed `routes.manifest.json` → the place allowlist. Pure, and Worker-safe: `places.ts`
3
+ * reads the file off disk for a Bun server, and a Worker hands over the JSON it bundles
4
+ * (`./d1.ts`). ONE rule for both — until task 2096 music's Worker carried its own copy of it.
5
+ *
6
+ * Anything it cannot read is `[]`, never a throw: see `places.ts`, "Missing is EMPTY".
7
+ */
8
+ import type { PlaceRef } from "./policy.js";
9
+
10
+ export function placesFromManifest(doc: unknown): PlaceRef[] {
11
+ const routes = Array.isArray(doc) ? doc : ((doc as { routes?: unknown } | null)?.routes as unknown[] | undefined);
12
+ if (!Array.isArray(routes)) return [];
13
+ const seen = new Set<string>();
14
+ const places: PlaceRef[] = [];
15
+ for (const raw of routes) {
16
+ const r = raw as { id?: unknown; path?: unknown } | null;
17
+ if (!r || typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
18
+ seen.add(r.id);
19
+ places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
20
+ }
21
+ // Sorted so the allowlist a board renders is stable between deployments.
22
+ places.sort((a, b) => a.id.localeCompare(b.id));
23
+ return places;
24
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { existsSync, readFileSync } from "node:fs";
26
26
  import { join } from "node:path";
27
+ import { placesFromManifest } from "./manifestPlaces.js";
27
28
 
28
29
  /** Where the manifest sits, in a source tree and in a built artifact alike. */
29
30
  const MANIFEST = "routes.manifest.json";
@@ -54,22 +55,7 @@ export function engagementPlacesFor(dir: string): EngagementPlace[] {
54
55
  const path = join(dir, MANIFEST);
55
56
  if (!existsSync(path)) return [];
56
57
  try {
57
- const doc = JSON.parse(readFileSync(path, "utf8")) as unknown;
58
- const routes = Array.isArray(doc)
59
- ? doc
60
- : ((doc as { routes?: unknown }).routes as unknown[] | undefined);
61
- if (!Array.isArray(routes)) return [];
62
- const seen = new Set<string>();
63
- const places: EngagementPlace[] = [];
64
- for (const raw of routes) {
65
- const r = raw as { id?: unknown; path?: unknown };
66
- if (typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
67
- seen.add(r.id);
68
- places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
69
- }
70
- // Sorted so the allowlist a board renders is stable between deployments.
71
- places.sort((a, b) => a.id.localeCompare(b.id));
72
- return places;
58
+ return placesFromManifest(JSON.parse(readFileSync(path, "utf8")) as unknown);
73
59
  } catch {
74
60
  return [];
75
61
  }
@@ -432,3 +432,26 @@ describe("two locked apps sharing a hostname", () => {
432
432
  expect((await a.guard.handle(xhr("/api/items", onlyB)))?.status).toBe(401);
433
433
  });
434
434
  });
435
+
436
+ describe("🔴 the status route answers for THIS CALLER, never the site", () => {
437
+ // The lock page's script reloads whenever status says `locked: false`. Read site-wide, one
438
+ // unlocked browser made every OTHER browser's lock page reload for ever (4.26.1).
439
+ test("another browser's unlock does not tell a cookieless caller it is in", async () => {
440
+ const { guard, lock } = build();
441
+ const cookie = await open(guard);
442
+ expect(lock.unlocked).toBe(true); // the site IS open, for somebody
443
+
444
+ const stranger = await (await guard.handle(xhr(MASTER_LOCK_PATHS.status)))?.json();
445
+ expect(stranger.locked).toBe(true);
446
+
447
+ const owner = await (await guard.handle(xhr(MASTER_LOCK_PATHS.status, cookie)))?.json();
448
+ expect(owner.locked).toBe(false);
449
+ });
450
+
451
+ test("a request-less health reading is still the site's", async () => {
452
+ const { guard, lock } = build();
453
+ expect(lock.status().locked).toBe(true);
454
+ await open(guard);
455
+ expect(lock.status().locked).toBe(false);
456
+ });
457
+ });
@@ -375,6 +375,16 @@ export class MasterLock {
375
375
  * `req` is optional only so a health check can ask without one. Pass it wherever there
376
376
  * IS a request: without it the reply carries no account, and a client would read that
377
377
  * as "locked" while holding a perfectly good unlock.
378
+ *
379
+ * 🔴 With a request, `locked` is THIS CALLER's answer — the one {@link accountFor} gives —
380
+ * and never the site's. Until 4.26.1 it read {@link unlocked} ("somebody's device is
381
+ * open"), which the lock page's own script takes as "you are in" and answers with
382
+ * `location.reload()`; the guard, asking the per-caller question, serves the lock page
383
+ * again, and the page spins for as long as any other browser stays unlocked — no password
384
+ * box ever settles. Measured 2026-09-23 on the binary-server inspector: a second Chromium
385
+ * context reloaded `/__lock/` in a tight loop while `/__lock/status` told it
386
+ * `"locked": false` with no cookie at all. Without a request it stays the site-wide
387
+ * health reading, which is all a caller with no request can mean.
378
388
  */
379
389
  status(req?: Request): MasterLockStatus {
380
390
  const accountId = req ? this.accountFor(req) : null;
@@ -382,7 +392,7 @@ export class MasterLock {
382
392
  return {
383
393
  configured: this.configured,
384
394
  enrollable: this.awaitingEnrollment,
385
- locked: !this.unlocked,
395
+ locked: req ? accountId === null : !this.unlocked,
386
396
  kdf: this.kdf,
387
397
  idleMs: this.idleMs,
388
398
  remainingMs: this.remainingMs,
@@ -89,3 +89,21 @@ describe('cursedbelt-server/telemetry', () => {
89
89
  expect(found).toEqual(['bun:sqlite']);
90
90
  });
91
91
  });
92
+
93
+ describe('🔴 cursedbelt-server/engagement/d1 — a Worker mounts it (task 2096)', () => {
94
+ test('reaches no `bun:` module at runtime — not the store, not the Bun-only barrel', () => {
95
+ const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'engagement', 'd1.ts'));
96
+ expect(bunImports).toEqual([]);
97
+ // Not vacuous: the policy and the manifest rule are both in the graph, and the store is not.
98
+ expect(files.some((f) => f.endsWith('engagement/policy.ts'))).toBe(true);
99
+ expect(files.some((f) => f.endsWith('engagement/manifestPlaces.ts'))).toBe(true);
100
+ expect(files.some((f) => f.endsWith('engagement/store.ts'))).toBe(false);
101
+ });
102
+
103
+ test("its export map's `import` is the real module, not the Bun-only refusal", () => {
104
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as {
105
+ exports: Record<string, { import?: string }>;
106
+ };
107
+ expect(pkg.exports['./engagement/d1']?.import).toBe('./dist/server/engagement/d1.js');
108
+ });
109
+ });