cursedbelt-server 4.28.0 → 4.30.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/README.md CHANGED
@@ -73,6 +73,12 @@ against one stale count (task 2137). Pass `attempts: createD1MasterLockAttempts(
73
73
  (one conditional UPSERT … RETURNING on the `meta` row `master_lock:attempts`).
74
74
  `src/server/master-lock/attempts.ts` has the why.
75
75
 
76
+ Its open unlocks go in rows of their own too: `sessions: createD1MasterLockSessions(db, writes,
77
+ { rows })`, one `meta` row per token digest under `master_lock:session:` (read them in the same
78
+ snapshot query with `masterLockSessionRange()`). In the one-blob `state` two devices' overlapping
79
+ requests each wrote back the map they read, and the owner was asked for the password again right
80
+ after typing it (task 2138). `src/server/master-lock/sessions.ts` has the why.
81
+
76
82
  ## Standards
77
83
 
78
84
  `docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` — the
@@ -25,6 +25,7 @@ export { MASTER_LOCK_CSP, type MasterLockGuard, type MasterLockGuardOptions, cre
25
25
  export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions, lockPageHtml, } from "./lockPage.js";
26
26
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals.js";
27
27
  export { MASTER_LOCK_ATTEMPTS_KEY, type MasterLockAttempts, createD1MasterLockAttempts, } from "./attempts.js";
28
- export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock.js";
28
+ export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStateStore, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock.js";
29
29
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed.js";
30
+ export { MASTER_LOCK_SESSION_KEY_PREFIX, type MasterLockSession, type MasterLockSessionStore, createD1MasterLockSessions, masterLockSessionRange, } from "./sessions.js";
30
31
  export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store.js";
@@ -27,4 +27,5 @@ export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrinci
27
27
  export { MASTER_LOCK_ATTEMPTS_KEY, createD1MasterLockAttempts, } from "./attempts.js";
28
28
  export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock.js";
29
29
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed.js";
30
+ export { MASTER_LOCK_SESSION_KEY_PREFIX, createD1MasterLockSessions, masterLockSessionRange, } from "./sessions.js";
30
31
  export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store.js";
@@ -1,5 +1,6 @@
1
1
  import { type MasterLockAccountSummary, type MasterLockKdfParams, type MasterLockStatus } from "cursedbelt-core/master-lock";
2
2
  import type { MasterLockAttempts } from "./attempts.js";
3
+ import type { MasterLockSessionStore } from "./sessions.js";
3
4
  /**
4
5
  * ONE master password, and therefore one TENANT of the app behind it.
5
6
  *
@@ -102,6 +103,14 @@ export interface MasterLockOptions {
102
103
  store: MasterLockStore;
103
104
  /** See {@link MasterLockStateStore}. Required wherever the instance does not outlive a request. */
104
105
  state?: MasterLockStateStore;
106
+ /**
107
+ * Where the open unlocks live, ONE ROW EACH — see `./sessions.ts`. Given, the sessions leave
108
+ * the {@link state} blob (which keeps only the throttle's own count, if anything), so two
109
+ * devices' overlapping requests touch different rows instead of each writing back the whole
110
+ * map it read (task 2138). A Worker passes `createD1MasterLockSessions`; omitted, nothing
111
+ * changes.
112
+ */
113
+ sessions?: MasterLockSessionStore;
105
114
  /**
106
115
  * The BOOTSTRAP record, as JSON, used only when the store is empty — the same shape as
107
116
  * {@link MasterLockRecord} with `idleMs` optional.
@@ -206,6 +215,8 @@ export declare class MasterLock {
206
215
  */
207
216
  private sessions;
208
217
  private readonly stateStore;
218
+ /** See {@link MasterLockOptions.sessions}. */
219
+ private readonly sessionStore;
209
220
  private expiryTimer;
210
221
  private failures;
211
222
  private lastFailureAt;
@@ -90,6 +90,8 @@ export class MasterLock {
90
90
  */
91
91
  sessions = new Map();
92
92
  stateStore = null;
93
+ /** See {@link MasterLockOptions.sessions}. */
94
+ sessionStore = null;
93
95
  expiryTimer = null;
94
96
  failures = 0;
95
97
  lastFailureAt = 0;
@@ -98,6 +100,7 @@ export class MasterLock {
98
100
  constructor(options) {
99
101
  this.store = options.store;
100
102
  this.stateStore = options.state ?? null;
103
+ this.sessionStore = options.sessions ?? null;
101
104
  this.attempts = options.attempts ?? this.ownAttempts();
102
105
  this.loadState();
103
106
  this.now = options.now ?? Date.now;
@@ -365,14 +368,24 @@ export class MasterLock {
365
368
  * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
366
369
  */
367
370
  open(accountId, now) {
371
+ const hadFailures = this.failures !== 0 || this.lastFailureAt !== 0;
368
372
  this.failures = 0;
369
373
  this.lastFailureAt = 0;
370
374
  const token = randomToken();
371
375
  const wasLocked = this.sessions.size === 0;
372
376
  // ADDED, never replacing: the desk and the phone are the same person and both stay
373
377
  // open. See the `sessions` note.
374
- this.sessions.set(tokenDigest(token), { accountId, lastActivityAt: now });
375
- this.saveState();
378
+ const digest = tokenDigest(token);
379
+ const session = { accountId, lastActivityAt: now };
380
+ this.sessions.set(digest, session);
381
+ if (this.sessionStore) {
382
+ this.sessionStore.open(digest, session);
383
+ if (hadFailures)
384
+ this.saveState();
385
+ }
386
+ else {
387
+ this.saveState();
388
+ }
376
389
  this.scheduleExpiry();
377
390
  if (wasLocked)
378
391
  this.onLockedChange?.(false);
@@ -537,13 +550,16 @@ export class MasterLock {
537
550
  // No early `return`, for the reason `accountFor` gives: the loop's duration
538
551
  // must not say which of the jar's cookies was this app's.
539
552
  if (constantTimeEqual(presented, live)) {
540
- this.sessions.set(live, { ...session, lastActivityAt: this.now() });
553
+ const lastActivityAt = this.now();
554
+ this.sessions.set(live, { ...session, lastActivityAt });
555
+ this.sessionStore?.touch(live, lastActivityAt);
541
556
  slid = true;
542
557
  }
543
558
  }
544
559
  }
545
560
  if (slid) {
546
- this.saveState();
561
+ if (!this.sessionStore)
562
+ this.saveState();
547
563
  this.scheduleExpiry();
548
564
  }
549
565
  return slid;
@@ -551,13 +567,18 @@ export class MasterLock {
551
567
  /** The manual lock — the owner's *"I want a lock option to turn the site back to needing
552
568
  * the master password"*. Also what a change of password does to every open page. */
553
569
  lock() {
570
+ // 🔴 With a session store the rows are deleted even when THIS snapshot holds none: a
571
+ // device that unlocked after this request read its snapshot is still a device, and
572
+ // "every device" is the requirement below.
573
+ this.sessionStore?.closeAll();
554
574
  if (this.sessions.size === 0)
555
575
  return;
556
576
  // EVERY device this person has open, not the one that pressed the button. The
557
577
  // owner's requirement is "no one can see anything until the master password is
558
578
  // entered again", and a lock that left the phone open would not be that.
559
579
  this.sessions.clear();
560
- this.saveState();
580
+ if (!this.sessionStore)
581
+ this.saveState();
561
582
  this.clearExpiry();
562
583
  this.onLockedChange?.(true);
563
584
  }
@@ -644,6 +665,9 @@ export class MasterLock {
644
665
  }
645
666
  /** Adopt the persisted live state, if there is a store and it holds a readable one. */
646
667
  loadState() {
668
+ for (const [digest, session] of this.sessionStore?.read() ?? []) {
669
+ this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
670
+ }
647
671
  if (!this.stateStore)
648
672
  return;
649
673
  const raw = this.stateStore.read();
@@ -653,7 +677,10 @@ export class MasterLock {
653
677
  const parsed = JSON.parse(raw);
654
678
  if (parsed.v !== 1)
655
679
  return;
656
- for (const [digest, session] of parsed.sessions ?? []) {
680
+ // With a session store the blob's sessions are not the truth — its rows are (task 2138).
681
+ // An unlock left in the blob by an older release is dropped, not adopted: adopting it
682
+ // would re-write a row a concurrent `lock()` may already have closed. Fails LOCKED, once.
683
+ for (const [digest, session] of this.sessionStore ? [] : (parsed.sessions ?? [])) {
657
684
  if (typeof digest === "string" && typeof session?.accountId === "string" && Number.isFinite(session.lastActivityAt)) {
658
685
  this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
659
686
  }
@@ -667,7 +694,13 @@ export class MasterLock {
667
694
  }
668
695
  /** Persist the live state — every mutation of it calls this, or a Worker forgets it. */
669
696
  saveState() {
670
- this.stateStore?.write(JSON.stringify({ v: 1, sessions: [...this.sessions], failures: this.failures, lastFailureAt: this.lastFailureAt }));
697
+ this.stateStore?.write(JSON.stringify({
698
+ v: 1,
699
+ // Not here when they have rows of their own — see {@link MasterLockOptions.sessions}.
700
+ sessions: this.sessionStore ? [] : [...this.sessions],
701
+ failures: this.failures,
702
+ lastFailureAt: this.lastFailureAt,
703
+ }));
671
704
  }
672
705
  /** Retire a session whose idle window has passed. Called on every read of the state, so
673
706
  * correctness never depends on the timer having fired. */
@@ -680,10 +713,12 @@ export class MasterLock {
680
713
  const before = this.sessions.size;
681
714
  for (const [token, session] of this.sessions) {
682
715
  // Each device expires on its OWN clock, so one going idle never shortens another.
683
- if (now - session.lastActivityAt > record.idleMs)
716
+ if (now - session.lastActivityAt > record.idleMs) {
684
717
  this.sessions.delete(token);
718
+ this.sessionStore?.expire(token, session.lastActivityAt);
719
+ }
685
720
  }
686
- if (this.sessions.size !== before)
721
+ if (this.sessions.size !== before && !this.sessionStore)
687
722
  this.saveState();
688
723
  if (wasOpen && this.sessions.size === 0) {
689
724
  this.clearExpiry();
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The master lock's open unlocks, ONE ROW EACH — so two devices' requests never overwrite each
3
+ * other's sessions.
4
+ *
5
+ * ── 🔴 Why this file exists (task 2138, 2026-09-24) ─────────────────────────
6
+ * A Worker rebuilds `MasterLock` on every request over a snapshot, and until 4.30.0 the open
7
+ * unlocks rode in ONE JSON blob (`MasterLockStateStore`, `master_lock:state`). Every mutation
8
+ * wrote the whole map back, so two requests that overlapped each wrote the map THEY read: the
9
+ * owner unlocks on the phone while the desk's `POST /__lock/ping` is in flight, the ping's write
10
+ * lands last, and the phone's brand-new session is gone — asked for the master password again
11
+ * right after typing it. A request that swept an expired session dropped any unlock that opened
12
+ * during it the same way. It failed LOCKED, so it was never a hole; it was a trust bug.
13
+ *
14
+ * With a {@link MasterLockSessionStore}, every mutation touches only the row it means:
15
+ * · `open` — insert this token's row. Nothing else is read or written.
16
+ * · `touch` — slide this row's clock, and ONLY if the row still exists and only forwards.
17
+ * 🔴 Never an upsert: a touch landing after a concurrent `lock()` must not
18
+ * resurrect the session the owner just closed on every device.
19
+ * · `expire` — delete this row, and only if it is still as idle as the sweep saw it, so a
20
+ * sweep racing a touch from another device cannot drop a session that was just used.
21
+ * · `closeAll` — delete every row under the prefix. The manual lock, and a password change.
22
+ *
23
+ * Omitted, the sessions stay where they were — in memory, or in the state blob — so the Mac's
24
+ * one-process daemons are unchanged.
25
+ */
26
+ import type { WriteCollector } from "../d1/invocation.js";
27
+ import type { D1LikeDatabase } from "../d1/types.js";
28
+ /** One live unlock: the token's account, and when it last saw real interaction. */
29
+ export interface MasterLockSession {
30
+ accountId: string;
31
+ lastActivityAt: number;
32
+ }
33
+ /**
34
+ * Where the open unlocks live when the lock does not outlive a request — one entry per token
35
+ * DIGEST (never the token). Synchronous by the same contract as `MasterLockStore`: `read` answers
36
+ * from the request's snapshot, and the mutations are queued for the entry point to settle.
37
+ */
38
+ export interface MasterLockSessionStore {
39
+ /** Every open unlock, as this request's snapshot read them. */
40
+ read(): Iterable<readonly [digest: string, session: MasterLockSession]>;
41
+ open(digest: string, session: MasterLockSession): void;
42
+ /** Slide an EXISTING session's clock forwards. Never creates one. */
43
+ touch(digest: string, lastActivityAt: number): void;
44
+ /** Retire a lapsed session — only if it has not been touched since `seenLastActivityAt`. */
45
+ expire(digest: string, seenLastActivityAt: number): void;
46
+ /** Every session, on every device. */
47
+ closeAll(): void;
48
+ }
49
+ /** The key prefix a D1 store keeps one session per row under, beside the lock's record. */
50
+ export declare const MASTER_LOCK_SESSION_KEY_PREFIX = "master_lock:session:";
51
+ /**
52
+ * The half-open key range `[low, high)` that holds exactly the keys under `prefix` — for a
53
+ * snapshot query that reads them with the lock's other rows. A range, not `LIKE`: `_` is a
54
+ * `LIKE` wildcard and this prefix is full of them.
55
+ */
56
+ export declare function masterLockSessionRange(prefix?: string): {
57
+ low: string;
58
+ high: string;
59
+ };
60
+ /**
61
+ * The store over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) — `meta` by default,
62
+ * the table a Worker port already keeps the lock's record in, so it needs no migration.
63
+ *
64
+ * `rows` is the request's snapshot, read by the caller in the same query as the lock's other keys
65
+ * (`masterLockSessionRange` is the range to add); rows outside the prefix are ignored, so the
66
+ * whole snapshot may be passed. Mutations go to `writes`, CHAINED so they land in the order they
67
+ * were made — a password change is `closeAll` then nothing, but a lock-then-open in one request
68
+ * must not race its own DELETE past its INSERT (`createPendingWrites` runs what it collects
69
+ * concurrently, and says so).
70
+ */
71
+ export declare function createD1MasterLockSessions(db: D1LikeDatabase, writes: WriteCollector, options: {
72
+ rows: Iterable<{
73
+ key: string;
74
+ value: string;
75
+ }>;
76
+ table?: string;
77
+ prefix?: string;
78
+ }): MasterLockSessionStore;
@@ -0,0 +1,67 @@
1
+ /** The key prefix a D1 store keeps one session per row under, beside the lock's record. */
2
+ export const MASTER_LOCK_SESSION_KEY_PREFIX = "master_lock:session:";
3
+ /**
4
+ * The half-open key range `[low, high)` that holds exactly the keys under `prefix` — for a
5
+ * snapshot query that reads them with the lock's other rows. A range, not `LIKE`: `_` is a
6
+ * `LIKE` wildcard and this prefix is full of them.
7
+ */
8
+ export function masterLockSessionRange(prefix = MASTER_LOCK_SESSION_KEY_PREFIX) {
9
+ const last = prefix.charCodeAt(prefix.length - 1);
10
+ return { low: prefix, high: prefix.slice(0, -1) + String.fromCharCode(last + 1) };
11
+ }
12
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ function parseSession(raw) {
14
+ try {
15
+ const parsed = JSON.parse(raw);
16
+ if (typeof parsed.accountId !== "string" || !Number.isFinite(parsed.lastActivityAt))
17
+ return null;
18
+ return { accountId: parsed.accountId, lastActivityAt: Number(parsed.lastActivityAt) };
19
+ }
20
+ catch {
21
+ // An unreadable row is no session: LOCKED, the safe reading.
22
+ return null;
23
+ }
24
+ }
25
+ /**
26
+ * The store over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) — `meta` by default,
27
+ * the table a Worker port already keeps the lock's record in, so it needs no migration.
28
+ *
29
+ * `rows` is the request's snapshot, read by the caller in the same query as the lock's other keys
30
+ * (`masterLockSessionRange` is the range to add); rows outside the prefix are ignored, so the
31
+ * whole snapshot may be passed. Mutations go to `writes`, CHAINED so they land in the order they
32
+ * were made — a password change is `closeAll` then nothing, but a lock-then-open in one request
33
+ * must not race its own DELETE past its INSERT (`createPendingWrites` runs what it collects
34
+ * concurrently, and says so).
35
+ */
36
+ export function createD1MasterLockSessions(db, writes, options) {
37
+ const table = options.table ?? "meta";
38
+ if (!IDENTIFIER.test(table))
39
+ throw new Error(`master lock sessions: "${table}" is not a table name`);
40
+ const prefix = options.prefix ?? MASTER_LOCK_SESSION_KEY_PREFIX;
41
+ const { low, high } = masterLockSessionRange(prefix);
42
+ const snapshot = [];
43
+ for (const row of options.rows) {
44
+ if (!row.key.startsWith(prefix))
45
+ continue;
46
+ const session = parseSession(row.value);
47
+ if (session)
48
+ snapshot.push([row.key.slice(prefix.length), session]);
49
+ }
50
+ let chain = Promise.resolve();
51
+ const queue = (sql, ...params) => {
52
+ chain = chain.then(() => db
53
+ .prepare(sql)
54
+ .bind(...params)
55
+ .run());
56
+ writes.add(chain);
57
+ };
58
+ const lastActivity = `json_extract(value, '$.lastActivityAt')`;
59
+ return {
60
+ read: () => snapshot,
61
+ open: (digest, session) => queue(`INSERT OR REPLACE INTO ${table} (key, value) VALUES (?, ?)`, prefix + digest, JSON.stringify({ accountId: session.accountId, lastActivityAt: session.lastActivityAt })),
62
+ touch: (digest, lastActivityAt) => queue(`UPDATE ${table} SET value = json_set(value, '$.lastActivityAt', ?2)
63
+ WHERE key = ?1 AND json_valid(value) AND ${lastActivity} < ?2`, prefix + digest, lastActivityAt),
64
+ expire: (digest, seenLastActivityAt) => queue(`DELETE FROM ${table} WHERE key = ?1 AND (NOT json_valid(value) OR ${lastActivity} <= ?2)`, prefix + digest, seenLastActivityAt),
65
+ closeAll: () => queue(`DELETE FROM ${table} WHERE key >= ? AND key < ?`, low, high),
66
+ };
67
+ }
@@ -27,8 +27,6 @@ export interface MetricRow {
27
27
  region?: string | null;
28
28
  city?: string | null;
29
29
  }
30
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
31
- export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
32
30
  export interface MetricsBufferOptions {
33
31
  db: Database;
34
32
  /** Flush cadence in ms. Default: 1500. */
@@ -1,5 +1,8 @@
1
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
2
- export const PLACE_COLUMNS = ['country', 'region', 'city'];
1
+ import { PLACE_COLUMNS } from './requestPlace.js';
2
+ // Every place column is a `MetricRow` field — a column declared in `requestPlace.ts` that this
3
+ // row cannot carry fails to compile here rather than inserting `undefined` for ever.
4
+ const _placeColumnsAreRowFields = PLACE_COLUMNS;
5
+ void _placeColumnsAreRowFields;
3
6
  export function createMetricsBuffer(opts) {
4
7
  const { db, flushIntervalMs = 1500, maxSize = 5000 } = opts;
5
8
  let buffer = [];
@@ -30,6 +30,14 @@ export interface RequestPlace {
30
30
  }
31
31
  /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
32
32
  export type RequestLocator = (request: Request) => RequestPlace | null;
33
+ /**
34
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
35
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
36
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
37
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
38
+ * fourth column added here would have been written by the buffer and dropped by all three.
39
+ */
40
+ export declare const PLACE_COLUMNS: readonly ["country", "region", "city"];
33
41
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
34
42
  export declare const PLACE_FIELD_MAX_CHARS = 96;
35
43
  /**
@@ -22,6 +22,14 @@
22
22
  *
23
23
  * Worker-safe: no `bun:` import (`telemetryIsWorkerSafe.spec.ts` walks this graph).
24
24
  */
25
+ /**
26
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
27
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
28
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
29
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
30
+ * fourth column added here would have been written by the buffer and dropped by all three.
31
+ */
32
+ export const PLACE_COLUMNS = ['country', 'region', 'city'];
25
33
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
26
34
  export const PLACE_FIELD_MAX_CHARS = 96;
27
35
  /** Cloudflare's "no country could be determined" code — an unknown, not a place. */
@@ -62,7 +62,7 @@ import { Database } from "bun:sqlite";
62
62
  import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
63
  import { join } from "node:path";
64
64
  import { applyCcPragmas } from "cwip/sqlite";
65
- import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
65
+ import { PLACE_COLUMNS } from "../metrics/requestPlace.js";
66
66
  import { createTelemetrySink } from "../metrics/telemetrySink.js";
67
67
  import { requestLogger } from "../middleware/requestLogger.js";
68
68
  import { runMigrations } from "../migration/runner.js";
@@ -17,4 +17,4 @@
17
17
  */
18
18
  export { type RequestLoggerOpts, requestLogger } from './middleware/requestLogger.js';
19
19
  export { type AnalyticsEngineDataset, createTelemetrySink, TELEMETRY_POINT_VERSION, type TelemetryEvent, type TelemetrySink, type TelemetrySinkOptions, toDataPoint, } from './metrics/telemetrySink.js';
20
- export { locateFromCloudflare, type RequestLocator, type RequestPlace, } from './metrics/requestPlace.js';
20
+ export { locateFromCloudflare, PLACE_COLUMNS, type RequestLocator, type RequestPlace, } from './metrics/requestPlace.js';
@@ -17,4 +17,4 @@
17
17
  */
18
18
  export { requestLogger } from './middleware/requestLogger.js';
19
19
  export { createTelemetrySink, TELEMETRY_POINT_VERSION, toDataPoint, } from './metrics/telemetrySink.js';
20
- export { locateFromCloudflare, } from './metrics/requestPlace.js';
20
+ export { locateFromCloudflare, PLACE_COLUMNS, } from './metrics/requestPlace.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.28.0",
3
+ "version": "4.30.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.",
@@ -340,7 +340,7 @@
340
340
  "dependencies": {
341
341
  "argon2id": "1.0.1",
342
342
  "cursedbelt-core": "^2.1.1",
343
- "cursedops": "^0.6.0",
343
+ "cursedops": "^0.8.1",
344
344
  "cwip": "^4.6.0",
345
345
  "jose": "^6.2.3"
346
346
  },
@@ -380,7 +380,7 @@
380
380
  "@node-rs/argon2": "^2.0.2",
381
381
  "@types/bun": "^1.3.14",
382
382
  "@types/node": "^24",
383
- "cursedbelt": "^4.5.0",
383
+ "cursedbelt": "^5.7.2",
384
384
  "hono": "4.12.28",
385
385
  "kysely": "^0.28.17",
386
386
  "kysely-bun-sqlite": "^0.4.0",
@@ -59,6 +59,7 @@ export {
59
59
  type MasterLockEnrollResult,
60
60
  type MasterLockOptions,
61
61
  type MasterLockRecord,
62
+ type MasterLockStateStore,
62
63
  type MasterLockStore,
63
64
  type MasterLockUnlockResult,
64
65
  readRecord,
@@ -70,6 +71,13 @@ export {
70
71
  mintMasterLockSeed,
71
72
  serializeMasterLockSeed,
72
73
  } from "./seed.js";
74
+ export {
75
+ MASTER_LOCK_SESSION_KEY_PREFIX,
76
+ type MasterLockSession,
77
+ type MasterLockSessionStore,
78
+ createD1MasterLockSessions,
79
+ masterLockSessionRange,
80
+ } from "./sessions.js";
73
81
  export {
74
82
  MASTER_LOCK_SETTING_KEY,
75
83
  MASTER_LOCK_STATE_KEY,
@@ -58,6 +58,7 @@ import {
58
58
  windowRemaining,
59
59
  } from "cursedbelt-core/master-lock";
60
60
  import type { MasterLockAttempts, MasterLockCharge } from "./attempts.js";
61
+ import type { MasterLockSession, MasterLockSessionStore } from "./sessions.js";
61
62
 
62
63
  /**
63
64
  * ONE master password, and therefore one TENANT of the app behind it.
@@ -166,6 +167,14 @@ export interface MasterLockOptions {
166
167
  store: MasterLockStore;
167
168
  /** See {@link MasterLockStateStore}. Required wherever the instance does not outlive a request. */
168
169
  state?: MasterLockStateStore;
170
+ /**
171
+ * Where the open unlocks live, ONE ROW EACH — see `./sessions.ts`. Given, the sessions leave
172
+ * the {@link state} blob (which keeps only the throttle's own count, if anything), so two
173
+ * devices' overlapping requests touch different rows instead of each writing back the whole
174
+ * map it read (task 2138). A Worker passes `createD1MasterLockSessions`; omitted, nothing
175
+ * changes.
176
+ */
177
+ sessions?: MasterLockSessionStore;
169
178
  /**
170
179
  * The BOOTSTRAP record, as JSON, used only when the store is empty — the same shape as
171
180
  * {@link MasterLockRecord} with `idleMs` optional.
@@ -225,12 +234,6 @@ export type MasterLockAddAccountResult =
225
234
  | { ok: true; account: MasterLockAccountSummary }
226
235
  | { ok: false; reason: "wrong" | "invalid" | "duplicate" | "unconfigured" | "locked" };
227
236
 
228
- /** One live unlock: the token's account, and when it last saw real interaction. */
229
- interface MasterLockSession {
230
- accountId: string;
231
- lastActivityAt: number;
232
- }
233
-
234
237
  const TOKEN_BYTES = 24;
235
238
 
236
239
  /**
@@ -269,6 +272,8 @@ export class MasterLock {
269
272
  */
270
273
  private sessions = new Map<string, MasterLockSession>();
271
274
  private readonly stateStore: MasterLockStateStore | null = null;
275
+ /** See {@link MasterLockOptions.sessions}. */
276
+ private readonly sessionStore: MasterLockSessionStore | null = null;
272
277
  private expiryTimer: ReturnType<typeof setTimeout> | null = null;
273
278
 
274
279
  private failures = 0;
@@ -279,6 +284,7 @@ export class MasterLock {
279
284
  constructor(options: MasterLockOptions) {
280
285
  this.store = options.store;
281
286
  this.stateStore = options.state ?? null;
287
+ this.sessionStore = options.sessions ?? null;
282
288
  this.attempts = options.attempts ?? this.ownAttempts();
283
289
  this.loadState();
284
290
  this.now = options.now ?? Date.now;
@@ -555,14 +561,22 @@ export class MasterLock {
555
561
  * created, so `unlock` and `enroll` cannot disagree about what an unlock is.
556
562
  */
557
563
  private open(accountId: string, now: number): { token: string; accountId: string } {
564
+ const hadFailures = this.failures !== 0 || this.lastFailureAt !== 0;
558
565
  this.failures = 0;
559
566
  this.lastFailureAt = 0;
560
567
  const token = randomToken();
561
568
  const wasLocked = this.sessions.size === 0;
562
569
  // ADDED, never replacing: the desk and the phone are the same person and both stay
563
570
  // open. See the `sessions` note.
564
- this.sessions.set(tokenDigest(token), { accountId, lastActivityAt: now });
565
- this.saveState();
571
+ const digest = tokenDigest(token);
572
+ const session = { accountId, lastActivityAt: now };
573
+ this.sessions.set(digest, session);
574
+ if (this.sessionStore) {
575
+ this.sessionStore.open(digest, session);
576
+ if (hadFailures) this.saveState();
577
+ } else {
578
+ this.saveState();
579
+ }
566
580
  this.scheduleExpiry();
567
581
  if (wasLocked) this.onLockedChange?.(false);
568
582
  return { token, accountId };
@@ -737,13 +751,15 @@ export class MasterLock {
737
751
  // No early `return`, for the reason `accountFor` gives: the loop's duration
738
752
  // must not say which of the jar's cookies was this app's.
739
753
  if (constantTimeEqual(presented, live)) {
740
- this.sessions.set(live, { ...session, lastActivityAt: this.now() });
754
+ const lastActivityAt = this.now();
755
+ this.sessions.set(live, { ...session, lastActivityAt });
756
+ this.sessionStore?.touch(live, lastActivityAt);
741
757
  slid = true;
742
758
  }
743
759
  }
744
760
  }
745
761
  if (slid) {
746
- this.saveState();
762
+ if (!this.sessionStore) this.saveState();
747
763
  this.scheduleExpiry();
748
764
  }
749
765
  return slid;
@@ -752,12 +768,16 @@ export class MasterLock {
752
768
  /** The manual lock — the owner's *"I want a lock option to turn the site back to needing
753
769
  * the master password"*. Also what a change of password does to every open page. */
754
770
  lock(): void {
771
+ // 🔴 With a session store the rows are deleted even when THIS snapshot holds none: a
772
+ // device that unlocked after this request read its snapshot is still a device, and
773
+ // "every device" is the requirement below.
774
+ this.sessionStore?.closeAll();
755
775
  if (this.sessions.size === 0) return;
756
776
  // EVERY device this person has open, not the one that pressed the button. The
757
777
  // owner's requirement is "no one can see anything until the master password is
758
778
  // entered again", and a lock that left the phone open would not be that.
759
779
  this.sessions.clear();
760
- this.saveState();
780
+ if (!this.sessionStore) this.saveState();
761
781
  this.clearExpiry();
762
782
  this.onLockedChange?.(true);
763
783
  }
@@ -849,6 +869,9 @@ export class MasterLock {
849
869
 
850
870
  /** Adopt the persisted live state, if there is a store and it holds a readable one. */
851
871
  private loadState(): void {
872
+ for (const [digest, session] of this.sessionStore?.read() ?? []) {
873
+ this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
874
+ }
852
875
  if (!this.stateStore) return;
853
876
  const raw = this.stateStore.read();
854
877
  if (!raw) return;
@@ -860,7 +883,10 @@ export class MasterLock {
860
883
  lastFailureAt?: number;
861
884
  };
862
885
  if (parsed.v !== 1) return;
863
- for (const [digest, session] of parsed.sessions ?? []) {
886
+ // With a session store the blob's sessions are not the truth — its rows are (task 2138).
887
+ // An unlock left in the blob by an older release is dropped, not adopted: adopting it
888
+ // would re-write a row a concurrent `lock()` may already have closed. Fails LOCKED, once.
889
+ for (const [digest, session] of this.sessionStore ? [] : (parsed.sessions ?? [])) {
864
890
  if (typeof digest === "string" && typeof session?.accountId === "string" && Number.isFinite(session.lastActivityAt)) {
865
891
  this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
866
892
  }
@@ -875,7 +901,13 @@ export class MasterLock {
875
901
  /** Persist the live state — every mutation of it calls this, or a Worker forgets it. */
876
902
  private saveState(): void {
877
903
  this.stateStore?.write(
878
- JSON.stringify({ v: 1, sessions: [...this.sessions], failures: this.failures, lastFailureAt: this.lastFailureAt }),
904
+ JSON.stringify({
905
+ v: 1,
906
+ // Not here when they have rows of their own — see {@link MasterLockOptions.sessions}.
907
+ sessions: this.sessionStore ? [] : [...this.sessions],
908
+ failures: this.failures,
909
+ lastFailureAt: this.lastFailureAt,
910
+ }),
879
911
  );
880
912
  }
881
913
 
@@ -889,9 +921,12 @@ export class MasterLock {
889
921
  const before = this.sessions.size;
890
922
  for (const [token, session] of this.sessions) {
891
923
  // Each device expires on its OWN clock, so one going idle never shortens another.
892
- if (now - session.lastActivityAt > record.idleMs) this.sessions.delete(token);
924
+ if (now - session.lastActivityAt > record.idleMs) {
925
+ this.sessions.delete(token);
926
+ this.sessionStore?.expire(token, session.lastActivityAt);
927
+ }
893
928
  }
894
- if (this.sessions.size !== before) this.saveState();
929
+ if (this.sessions.size !== before && !this.sessionStore) this.saveState();
895
930
  if (wasOpen && this.sessions.size === 0) {
896
931
  this.clearExpiry();
897
932
  this.onLockedChange?.(true);
@@ -0,0 +1,247 @@
1
+ /**
2
+ * 🔴 The open unlocks are ONE ROW EACH (task 2138) — two devices' overlapping requests never
3
+ * overwrite each other's sessions.
4
+ *
5
+ * Every test builds the lock the way a Worker does: per REQUEST, over a snapshot read from D1
6
+ * before the request runs, with its writes collected and settled at the end. "In flight
7
+ * together" therefore means: both requests snapshot, then both act, then both settle — the
8
+ * interleaving that made the blob lose an unlock. The first test runs that interleaving over the
9
+ * blob and shows the loss, so the rest are not passing vacuously.
10
+ */
11
+ import { Database } from "bun:sqlite";
12
+ import { beforeAll, describe, expect, test } from "bun:test";
13
+ import { type MasterLockKdfParams, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
14
+ import { createFakeD1Binding } from "../d1/fakeD1.js";
15
+ import { createPendingWrites } from "../d1/invocation.js";
16
+ import { createRemoteD1 } from "../d1/remote.js";
17
+ import type { D1LikeDatabase, D1LikeStatement } from "../d1/types.js";
18
+ import { MasterLock } from "./masterLock.js";
19
+ import { MASTER_LOCK_SESSION_KEY_PREFIX, createD1MasterLockSessions, masterLockSessionRange } from "./sessions.js";
20
+ import { MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore } from "./store.js";
21
+
22
+ const KDF: MasterLockKdfParams = { v: 1, alg: "PBKDF2-SHA256", iter: 1, salt: "AAECAwQFBgcICQoLDA0ODw" };
23
+ const IDLE_MS = 10 * 60_000;
24
+
25
+ let VERIFIER = "";
26
+ let SEED = "";
27
+ beforeAll(async () => {
28
+ VERIFIER = await deriveMasterLockVerifier("the owner's master password", KDF);
29
+ const verifierHash = await Bun.password.hash(VERIFIER, { algorithm: "argon2id", memoryCost: 4096, timeCost: 1 });
30
+ SEED = JSON.stringify({ kdf: KDF, verifierHash, idleMs: IDLE_MS });
31
+ });
32
+
33
+ const req = (token: string): Request => new Request("http://x/", { headers: { cookie: `master_lock=${token}` } });
34
+
35
+ /**
36
+ * Real D1 is a network round trip per statement, so two statements in flight land in whatever
37
+ * order the network picks. The fake runs them in call order; this makes every DELETE slow, so a
38
+ * store that does not order its own writes is caught doing it.
39
+ */
40
+ function slowDeletes(db: D1LikeDatabase): D1LikeDatabase {
41
+ const wrap = (sql: string, statement: D1LikeStatement): D1LikeStatement => ({
42
+ bind: (...values) => wrap(sql, statement.bind(...values)),
43
+ first: ((column?: string) => (column === undefined ? statement.first() : statement.first(column))) as D1LikeStatement["first"],
44
+ all: () => statement.all(),
45
+ raw: () => statement.raw(),
46
+ run: async () => {
47
+ if (/^\s*DELETE/i.test(sql)) await new Promise((resolve) => setTimeout(resolve, 20));
48
+ return statement.run();
49
+ },
50
+ });
51
+ return Object.assign(Object.create(db) as D1LikeDatabase, { prepare: (sql: string) => wrap(sql, db.prepare(sql)) });
52
+ }
53
+
54
+ function world() {
55
+ const sqlite = new Database(":memory:");
56
+ sqlite.exec("CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL )");
57
+ const db: D1LikeDatabase = slowDeletes(createRemoteD1(createFakeD1Binding(sqlite)));
58
+ const record = createMemoryMasterLockStore(null);
59
+ let clock = 1_000_000;
60
+ const rows = () => sqlite.query("SELECT key FROM meta WHERE key >= ? ORDER BY key").all(MASTER_LOCK_SESSION_KEY_PREFIX) as { key: string }[];
61
+
62
+ /** One Worker request: snapshot, then a lock over it. `settle` is the entry point's await. */
63
+ async function request() {
64
+ const { low, high } = masterLockSessionRange();
65
+ const snapshot = (await db.prepare("SELECT key, value FROM meta WHERE key >= ? AND key < ?").bind(low, high).all<{ key: string; value: string }>())
66
+ .results;
67
+ const writes = createPendingWrites();
68
+ const lock = new MasterLock({
69
+ store: record,
70
+ sessions: createD1MasterLockSessions(db, writes, { rows: snapshot }),
71
+ seedJson: SEED,
72
+ now: () => clock,
73
+ });
74
+ return { lock, settle: () => writes.settle() };
75
+ }
76
+
77
+ /** Does a brand-new request, after everything settled, honour this token? */
78
+ async function opens(token: string): Promise<boolean> {
79
+ const { lock } = await request();
80
+ return lock.presents(req(token));
81
+ }
82
+
83
+ async function unlocked(): Promise<string> {
84
+ const r = await request();
85
+ const result = await r.lock.unlock(VERIFIER);
86
+ if (!result.ok) throw new Error("expected an unlock");
87
+ await r.settle();
88
+ return result.token;
89
+ }
90
+
91
+ return { sqlite, db, request, opens, unlocked, rows, tick: (ms: number) => (clock += ms) };
92
+ }
93
+
94
+ describe("🔴 two devices in flight together (task 2138)", () => {
95
+ test("the defect: over the ONE-blob state, the ping's write lands last and the phone's unlock is gone", async () => {
96
+ // The pre-4.30.0 shape — `state` is one `meta` row holding every session.
97
+ const sqlite = new Database(":memory:");
98
+ sqlite.exec("CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL )");
99
+ const kv = {
100
+ get: (key: string) => (sqlite.query("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | null)?.value ?? null,
101
+ set: (key: string, value: string) => void sqlite.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value),
102
+ };
103
+ const record = createMemoryMasterLockStore(null);
104
+ /** A request whose state reads are a SNAPSHOT taken now, and whose writes land when it settles. */
105
+ const request = () => {
106
+ const seen = kv.get(MASTER_LOCK_STATE_KEY);
107
+ let pending: string | null = null;
108
+ const lock = new MasterLock({
109
+ store: record,
110
+ state: { read: () => seen, write: (json) => void (pending = json) },
111
+ seedJson: SEED,
112
+ });
113
+ return { lock, settle: () => pending !== null && createKvMasterLockStore(kv, MASTER_LOCK_STATE_KEY).write(pending) };
114
+ };
115
+ const desk = request();
116
+ const deskUnlock = await desk.lock.unlock(VERIFIER);
117
+ desk.settle();
118
+ if (!deskUnlock.ok) throw new Error("expected an unlock");
119
+
120
+ const phone = request();
121
+ const ping = request();
122
+ const phoneUnlock = await phone.lock.unlock(VERIFIER);
123
+ if (!phoneUnlock.ok) throw new Error("expected an unlock");
124
+ expect(ping.lock.touch(req(deskUnlock.token))).toBe(true);
125
+ phone.settle();
126
+ ping.settle(); // lands last
127
+
128
+ expect(request().lock.presents(req(phoneUnlock.token))).toBe(false);
129
+ });
130
+
131
+ test("an unlock on the phone and a ping from the desk, together: BOTH tokens still open the site", async () => {
132
+ const w = world();
133
+ const deskToken = await w.unlocked();
134
+
135
+ const phone = await w.request();
136
+ const ping = await w.request();
137
+ const phoneUnlock = await phone.lock.unlock(VERIFIER);
138
+ if (!phoneUnlock.ok) throw new Error("expected an unlock");
139
+ expect(ping.lock.touch(req(deskToken))).toBe(true);
140
+ await phone.settle();
141
+ await ping.settle();
142
+
143
+ expect(await w.opens(phoneUnlock.token)).toBe(true);
144
+ expect(await w.opens(deskToken)).toBe(true);
145
+ expect(w.rows()).toHaveLength(2);
146
+ });
147
+
148
+ test("a sweep of one lapsed device does not drop an unlock that opened during it", async () => {
149
+ const w = world();
150
+ const stale = await w.unlocked();
151
+ w.tick(IDLE_MS + 1);
152
+
153
+ const sweeper = await w.request();
154
+ const phone = await w.request();
155
+ const phoneUnlock = await phone.lock.unlock(VERIFIER);
156
+ if (!phoneUnlock.ok) throw new Error("expected an unlock");
157
+ expect(sweeper.lock.unlocked).toBe(false); // sweeps `stale`
158
+ await phone.settle();
159
+ await sweeper.settle();
160
+
161
+ expect(await w.opens(phoneUnlock.token)).toBe(true);
162
+ expect(await w.opens(stale)).toBe(false);
163
+ expect(w.rows()).toHaveLength(1);
164
+ });
165
+
166
+ test("🔴 a touch landing after a concurrent lock does NOT resurrect the session", async () => {
167
+ const w = world();
168
+ const token = await w.unlocked();
169
+
170
+ const locker = await w.request();
171
+ const ping = await w.request();
172
+ locker.lock.lock();
173
+ expect(ping.lock.touch(req(token))).toBe(true); // its snapshot still had the session
174
+ await locker.settle();
175
+ await ping.settle(); // lands last
176
+
177
+ expect(await w.opens(token)).toBe(false);
178
+ expect(w.rows()).toHaveLength(0);
179
+ });
180
+
181
+ test("🔴 a lock whose snapshot is EMPTY still closes a device that unlocked after that snapshot", async () => {
182
+ const w = world();
183
+ const locker = await w.request(); // read nothing
184
+ const phoneToken = await w.unlocked();
185
+ locker.lock.lock();
186
+ await locker.settle();
187
+ expect(await w.opens(phoneToken)).toBe(false);
188
+ });
189
+
190
+ test("a sweep racing a touch from another device keeps the session that was just used", async () => {
191
+ const w = world();
192
+ const token = await w.unlocked();
193
+ const late = await w.request(); // snapshot taken BEFORE the touch below
194
+ w.tick(IDLE_MS - 1);
195
+ const toucher = await w.request();
196
+ expect(toucher.lock.touch(req(token))).toBe(true);
197
+ await toucher.settle();
198
+ w.tick(2); // `late` now judges its (stale) view of the session lapsed
199
+ expect(late.lock.presents(req(token))).toBe(false);
200
+ await late.settle();
201
+ expect(await w.opens(token)).toBe(true);
202
+ });
203
+
204
+ test("a password change in one request — lock, then nothing else — closes every device, in order", async () => {
205
+ const w = world();
206
+ await w.unlocked();
207
+ await w.unlocked();
208
+ const r = await w.request();
209
+ r.lock.lock();
210
+ const reopened = await r.lock.unlock(VERIFIER); // same request: its INSERT must follow its DELETE
211
+ if (!reopened.ok) throw new Error("expected an unlock");
212
+ await r.settle();
213
+ expect(w.rows()).toHaveLength(1);
214
+ expect(await w.opens(reopened.token)).toBe(true);
215
+ });
216
+
217
+ test("🔴 the store lands its own writes in the order they were made — a closeAll then an open keeps the open", async () => {
218
+ const w = world();
219
+ const writes = createPendingWrites();
220
+ const store = createD1MasterLockSessions(w.db, writes, { rows: [] });
221
+ store.closeAll(); // slow, like a real round trip can be
222
+ store.open("fresh", { accountId: "acct-1", lastActivityAt: 1 });
223
+ await writes.settle();
224
+ expect(w.rows().map((row) => row.key)).toEqual([`${MASTER_LOCK_SESSION_KEY_PREFIX}fresh`]);
225
+ });
226
+
227
+ test("the rows hold a digest, never the token — a leaked row opens nothing", async () => {
228
+ const w = world();
229
+ const token = await w.unlocked();
230
+ const dump = JSON.stringify(w.sqlite.query("SELECT key, value FROM meta").all());
231
+ expect(dump).not.toContain(token);
232
+ });
233
+
234
+ test("an unreadable row is no session", async () => {
235
+ const w = world();
236
+ w.sqlite.query("INSERT INTO meta (key, value) VALUES (?, ?)").run(`${MASTER_LOCK_SESSION_KEY_PREFIX}abc`, "{not json");
237
+ const { lock } = await w.request();
238
+ expect(lock.unlocked).toBe(false);
239
+ });
240
+
241
+ test("the range is exactly the prefix — `_` is not a wildcard here", () => {
242
+ const { low, high } = masterLockSessionRange();
243
+ expect(`${MASTER_LOCK_SESSION_KEY_PREFIX}zzz` >= low && `${MASTER_LOCK_SESSION_KEY_PREFIX}zzz` < high).toBe(true);
244
+ expect("master_lock:state" >= low && "master_lock:state" < high).toBe(false);
245
+ expect("masterXlock:session:a" >= low && "masterXlock:session:a" < high).toBe(false);
246
+ });
247
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The master lock's open unlocks, ONE ROW EACH — so two devices' requests never overwrite each
3
+ * other's sessions.
4
+ *
5
+ * ── 🔴 Why this file exists (task 2138, 2026-09-24) ─────────────────────────
6
+ * A Worker rebuilds `MasterLock` on every request over a snapshot, and until 4.30.0 the open
7
+ * unlocks rode in ONE JSON blob (`MasterLockStateStore`, `master_lock:state`). Every mutation
8
+ * wrote the whole map back, so two requests that overlapped each wrote the map THEY read: the
9
+ * owner unlocks on the phone while the desk's `POST /__lock/ping` is in flight, the ping's write
10
+ * lands last, and the phone's brand-new session is gone — asked for the master password again
11
+ * right after typing it. A request that swept an expired session dropped any unlock that opened
12
+ * during it the same way. It failed LOCKED, so it was never a hole; it was a trust bug.
13
+ *
14
+ * With a {@link MasterLockSessionStore}, every mutation touches only the row it means:
15
+ * · `open` — insert this token's row. Nothing else is read or written.
16
+ * · `touch` — slide this row's clock, and ONLY if the row still exists and only forwards.
17
+ * 🔴 Never an upsert: a touch landing after a concurrent `lock()` must not
18
+ * resurrect the session the owner just closed on every device.
19
+ * · `expire` — delete this row, and only if it is still as idle as the sweep saw it, so a
20
+ * sweep racing a touch from another device cannot drop a session that was just used.
21
+ * · `closeAll` — delete every row under the prefix. The manual lock, and a password change.
22
+ *
23
+ * Omitted, the sessions stay where they were — in memory, or in the state blob — so the Mac's
24
+ * one-process daemons are unchanged.
25
+ */
26
+ import type { WriteCollector } from "../d1/invocation.js";
27
+ import type { D1LikeBindable, D1LikeDatabase } from "../d1/types.js";
28
+
29
+ /** One live unlock: the token's account, and when it last saw real interaction. */
30
+ export interface MasterLockSession {
31
+ accountId: string;
32
+ lastActivityAt: number;
33
+ }
34
+
35
+ /**
36
+ * Where the open unlocks live when the lock does not outlive a request — one entry per token
37
+ * DIGEST (never the token). Synchronous by the same contract as `MasterLockStore`: `read` answers
38
+ * from the request's snapshot, and the mutations are queued for the entry point to settle.
39
+ */
40
+ export interface MasterLockSessionStore {
41
+ /** Every open unlock, as this request's snapshot read them. */
42
+ read(): Iterable<readonly [digest: string, session: MasterLockSession]>;
43
+ open(digest: string, session: MasterLockSession): void;
44
+ /** Slide an EXISTING session's clock forwards. Never creates one. */
45
+ touch(digest: string, lastActivityAt: number): void;
46
+ /** Retire a lapsed session — only if it has not been touched since `seenLastActivityAt`. */
47
+ expire(digest: string, seenLastActivityAt: number): void;
48
+ /** Every session, on every device. */
49
+ closeAll(): void;
50
+ }
51
+
52
+ /** The key prefix a D1 store keeps one session per row under, beside the lock's record. */
53
+ export const MASTER_LOCK_SESSION_KEY_PREFIX = "master_lock:session:";
54
+
55
+ /**
56
+ * The half-open key range `[low, high)` that holds exactly the keys under `prefix` — for a
57
+ * snapshot query that reads them with the lock's other rows. A range, not `LIKE`: `_` is a
58
+ * `LIKE` wildcard and this prefix is full of them.
59
+ */
60
+ export function masterLockSessionRange(prefix: string = MASTER_LOCK_SESSION_KEY_PREFIX): { low: string; high: string } {
61
+ const last = prefix.charCodeAt(prefix.length - 1);
62
+ return { low: prefix, high: prefix.slice(0, -1) + String.fromCharCode(last + 1) };
63
+ }
64
+
65
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
+
67
+ function parseSession(raw: string): MasterLockSession | null {
68
+ try {
69
+ const parsed = JSON.parse(raw) as Partial<MasterLockSession>;
70
+ if (typeof parsed.accountId !== "string" || !Number.isFinite(parsed.lastActivityAt)) return null;
71
+ return { accountId: parsed.accountId, lastActivityAt: Number(parsed.lastActivityAt) };
72
+ } catch {
73
+ // An unreadable row is no session: LOCKED, the safe reading.
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The store over a D1 key/value table (`key TEXT PRIMARY KEY, value TEXT`) — `meta` by default,
80
+ * the table a Worker port already keeps the lock's record in, so it needs no migration.
81
+ *
82
+ * `rows` is the request's snapshot, read by the caller in the same query as the lock's other keys
83
+ * (`masterLockSessionRange` is the range to add); rows outside the prefix are ignored, so the
84
+ * whole snapshot may be passed. Mutations go to `writes`, CHAINED so they land in the order they
85
+ * were made — a password change is `closeAll` then nothing, but a lock-then-open in one request
86
+ * must not race its own DELETE past its INSERT (`createPendingWrites` runs what it collects
87
+ * concurrently, and says so).
88
+ */
89
+ export function createD1MasterLockSessions(
90
+ db: D1LikeDatabase,
91
+ writes: WriteCollector,
92
+ options: { rows: Iterable<{ key: string; value: string }>; table?: string; prefix?: string },
93
+ ): MasterLockSessionStore {
94
+ const table = options.table ?? "meta";
95
+ if (!IDENTIFIER.test(table)) throw new Error(`master lock sessions: "${table}" is not a table name`);
96
+ const prefix = options.prefix ?? MASTER_LOCK_SESSION_KEY_PREFIX;
97
+ const { low, high } = masterLockSessionRange(prefix);
98
+
99
+ const snapshot: Array<readonly [string, MasterLockSession]> = [];
100
+ for (const row of options.rows) {
101
+ if (!row.key.startsWith(prefix)) continue;
102
+ const session = parseSession(row.value);
103
+ if (session) snapshot.push([row.key.slice(prefix.length), session]);
104
+ }
105
+
106
+ let chain: Promise<unknown> = Promise.resolve();
107
+ const queue = (sql: string, ...params: D1LikeBindable[]) => {
108
+ chain = chain.then(() =>
109
+ db
110
+ .prepare(sql)
111
+ .bind(...params)
112
+ .run(),
113
+ );
114
+ writes.add(chain);
115
+ };
116
+ const lastActivity = `json_extract(value, '$.lastActivityAt')`;
117
+
118
+ return {
119
+ read: () => snapshot,
120
+ open: (digest, session) =>
121
+ queue(
122
+ `INSERT OR REPLACE INTO ${table} (key, value) VALUES (?, ?)`,
123
+ prefix + digest,
124
+ JSON.stringify({ accountId: session.accountId, lastActivityAt: session.lastActivityAt }),
125
+ ),
126
+ touch: (digest, lastActivityAt) =>
127
+ queue(
128
+ `UPDATE ${table} SET value = json_set(value, '$.lastActivityAt', ?2)
129
+ WHERE key = ?1 AND json_valid(value) AND ${lastActivity} < ?2`,
130
+ prefix + digest,
131
+ lastActivityAt,
132
+ ),
133
+ expire: (digest, seenLastActivityAt) =>
134
+ queue(
135
+ `DELETE FROM ${table} WHERE key = ?1 AND (NOT json_valid(value) OR ${lastActivity} <= ?2)`,
136
+ prefix + digest,
137
+ seenLastActivityAt,
138
+ ),
139
+ closeAll: () => queue(`DELETE FROM ${table} WHERE key >= ? AND key < ?`, low, high),
140
+ };
141
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Database } from 'bun:sqlite';
2
+ import { PLACE_COLUMNS } from './requestPlace.js';
2
3
 
3
4
  /**
4
5
  * In-memory, batched writer for `request_metrics`. To keep request latency
@@ -30,8 +31,10 @@ export interface MetricRow {
30
31
  city?: string | null;
31
32
  }
32
33
 
33
- /** The optional place columns, in insert order. Each is written only when the table HAS it. */
34
- export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
34
+ // Every place column is a `MetricRow` field — a column declared in `requestPlace.ts` that this
35
+ // row cannot carry fails to compile here rather than inserting `undefined` for ever.
36
+ const _placeColumnsAreRowFields: readonly (keyof MetricRow)[] = PLACE_COLUMNS;
37
+ void _placeColumnsAreRowFields;
35
38
 
36
39
  export interface MetricsBufferOptions {
37
40
  db: Database;
@@ -33,6 +33,15 @@ export interface RequestPlace {
33
33
  /** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
34
34
  export type RequestLocator = (request: Request) => RequestPlace | null;
35
35
 
36
+ /**
37
+ * The place columns, in insert order — the ONE declaration every reader and migration uses
38
+ * (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
39
+ * Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
40
+ * private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
41
+ * fourth column added here would have been written by the buffer and dropped by all three.
42
+ */
43
+ export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
44
+
36
45
  /** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
37
46
  export const PLACE_FIELD_MAX_CHARS = 96;
38
47
 
@@ -63,7 +63,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statS
63
63
  import { join } from "node:path";
64
64
  import { applyCcPragmas } from "cwip/sqlite";
65
65
  import type { MiddlewareHandler } from "hono";
66
- import { PLACE_COLUMNS } from "../metrics/metricsBuffer.js";
66
+ import { PLACE_COLUMNS } from "../metrics/requestPlace.js";
67
67
  import type { RequestLocator } from "../metrics/requestPlace.js";
68
68
  import { createTelemetrySink, type TelemetrySink } from "../metrics/telemetrySink.js";
69
69
  import { requestLogger } from "../middleware/requestLogger.js";
@@ -27,6 +27,7 @@ export {
27
27
  } from './metrics/telemetrySink.js';
28
28
  export {
29
29
  locateFromCloudflare,
30
+ PLACE_COLUMNS,
30
31
  type RequestLocator,
31
32
  type RequestPlace,
32
33
  } from './metrics/requestPlace.js';
@@ -88,6 +88,14 @@ describe('cursedbelt-server/telemetry', () => {
88
88
  const found = [..."import { Database } from 'bun:sqlite';\nimport type { X } from 'bun:sqlite';".matchAll(regex)].map((m) => m[1]);
89
89
  expect(found).toEqual(['bun:sqlite']);
90
90
  });
91
+
92
+ test('publishes PLACE_COLUMNS — the one declaration the buffer writes and every app reads (task 2141)', async () => {
93
+ const telemetry = await import('./telemetry.ts');
94
+ expect(telemetry.PLACE_COLUMNS).toEqual(['country', 'region', 'city']);
95
+ // The same array the Bun buffer inserts from, not a second copy of it.
96
+ const { PLACE_COLUMNS } = await import('./metrics/requestPlace.ts');
97
+ expect(telemetry.PLACE_COLUMNS).toBe(PLACE_COLUMNS);
98
+ });
91
99
  });
92
100
 
93
101
  describe('🔴 cursedbelt-server/engagement/d1 — a Worker mounts it (task 2096)', () => {