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 +6 -0
- package/dist/server/master-lock/index.d.ts +2 -1
- package/dist/server/master-lock/index.js +1 -0
- package/dist/server/master-lock/masterLock.d.ts +11 -0
- package/dist/server/master-lock/masterLock.js +44 -9
- package/dist/server/master-lock/sessions.d.ts +78 -0
- package/dist/server/master-lock/sessions.js +67 -0
- package/dist/server/metrics/metricsBuffer.d.ts +0 -2
- package/dist/server/metrics/metricsBuffer.js +5 -2
- package/dist/server/metrics/requestPlace.d.ts +8 -0
- package/dist/server/metrics/requestPlace.js +8 -0
- package/dist/server/requestLog/requestMetrics.js +1 -1
- package/dist/server/telemetry.d.ts +1 -1
- package/dist/server/telemetry.js +1 -1
- package/package.json +3 -3
- package/src/server/master-lock/index.ts +8 -0
- package/src/server/master-lock/masterLock.ts +50 -15
- package/src/server/master-lock/sessions.spec.ts +247 -0
- package/src/server/master-lock/sessions.ts +141 -0
- package/src/server/metrics/metricsBuffer.ts +5 -2
- package/src/server/metrics/requestPlace.ts +9 -0
- package/src/server/requestLog/requestMetrics.ts +1 -1
- package/src/server/telemetry.ts +1 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +8 -0
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
2
|
-
|
|
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/
|
|
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';
|
package/dist/server/telemetry.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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)
|
|
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
|
-
|
|
34
|
-
|
|
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/
|
|
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";
|
package/src/server/telemetry.ts
CHANGED
|
@@ -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)', () => {
|