cursedbelt-server 2.0.0 → 3.0.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.
Files changed (82) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/master-lock/guard.d.ts +10 -0
  36. package/dist/server/master-lock/guard.js +70 -19
  37. package/dist/server/master-lock/index.d.ts +1 -1
  38. package/dist/server/master-lock/index.js +1 -1
  39. package/dist/server/master-lock/lockPage.d.ts +1 -1
  40. package/dist/server/master-lock/lockPage.js +68 -3
  41. package/dist/server/master-lock/masterLock.d.ts +250 -76
  42. package/dist/server/master-lock/masterLock.js +426 -114
  43. package/dist/server/master-lock/principals.js +6 -1
  44. package/dist/server/master-lock/seed.d.ts +5 -1
  45. package/dist/server/master-lock/seed.js +18 -1
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/server/bench/assert.ts +192 -0
  49. package/src/server/bench/budget.spec.ts +126 -0
  50. package/src/server/bench/budget.ts +207 -0
  51. package/src/server/bench/cpuBudget.spec.ts +302 -0
  52. package/src/server/bench/cpuBudget.ts +81 -0
  53. package/src/server/bench/cpuClock.ts +119 -0
  54. package/src/server/bench/index.ts +81 -0
  55. package/src/server/bench/recorder.ts +163 -0
  56. package/src/server/bench/runBench.ts +110 -0
  57. package/src/server/d1/backup.spec.ts +121 -0
  58. package/src/server/d1/backup.ts +186 -0
  59. package/src/server/d1/fakeD1.ts +193 -0
  60. package/src/server/d1/index.ts +62 -0
  61. package/src/server/d1/kysely.spec.ts +145 -0
  62. package/src/server/d1/kysely.ts +169 -0
  63. package/src/server/d1/limits.spec.ts +90 -0
  64. package/src/server/d1/limits.ts +123 -0
  65. package/src/server/d1/local.ts +173 -0
  66. package/src/server/d1/remote.ts +182 -0
  67. package/src/server/d1/sameShape.spec.ts +279 -0
  68. package/src/server/d1/scheduling.spec.ts +120 -0
  69. package/src/server/d1/scheduling.ts +210 -0
  70. package/src/server/d1/types.ts +163 -0
  71. package/src/server/d1/values.ts +138 -0
  72. package/src/server/master-lock/accounts.spec.ts +308 -0
  73. package/src/server/master-lock/guard.spec.ts +69 -7
  74. package/src/server/master-lock/guard.ts +78 -20
  75. package/src/server/master-lock/index.ts +3 -0
  76. package/src/server/master-lock/lockPage.ts +70 -3
  77. package/src/server/master-lock/masterLock.spec.ts +56 -23
  78. package/src/server/master-lock/masterLock.ts +529 -151
  79. package/src/server/master-lock/principals.spec.ts +45 -15
  80. package/src/server/master-lock/principals.ts +6 -1
  81. package/src/server/master-lock/seed.spec.ts +7 -2
  82. package/src/server/master-lock/seed.ts +22 -2
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The async database seam every app crosses to reach SQLite — locally on `bun:sqlite`,
3
+ * remotely on Cloudflare D1.
4
+ *
5
+ * ## 🔴 It is async on BOTH sides, and that is the entire point
6
+ *
7
+ * `bun:sqlite` is synchronous: `db.query(sql).all()` returns rows. D1 is
8
+ * `await db.prepare(sql).all()`. No adapter, no proxy and no `Proxy` trap makes a
9
+ * synchronous call site await — so the port is a real edit at every call site, and the
10
+ * only question this seam answers is whether that edit has to happen TWICE.
11
+ *
12
+ * A seam that is sync locally and async remotely passes every test on this machine and
13
+ * fails in production. So {@link D1LikeDatabase} is the same async shape on both sides,
14
+ * the local driver is the one that changes, and a ported call site runs unmodified
15
+ * against either.
16
+ *
17
+ * ## 🔴 The local driver is as STRICT as the remote one, never as lenient as Bun
18
+ *
19
+ * The same argument applies one level down, and it is where the real bugs live. Measured
20
+ * against `bun:sqlite` on 2026-09-16, four things differ between the two drivers, and two
21
+ * of them are silent-here / throw-there:
22
+ *
23
+ * | value | `bun:sqlite` | D1 (`workerd`) |
24
+ * |----------------------|-------------------------------|-------------------------|
25
+ * | `undefined` bind | 🔴 silently binds NULL | throws |
26
+ * | `boolean` bind | 🔴 silently coerces to 0/1 | throws |
27
+ * | BLOB column read | `Uint8Array` | `number[]` |
28
+ * | `.run()` result | `{changes, lastInsertRowid}` | `{success, meta, …}` |
29
+ *
30
+ * Lenient-locally is the same defect as sync-locally wearing a different costume: it is
31
+ * green on this Mac and red in the Worker. `normalizeBind` in `./values` therefore makes
32
+ * the LOCAL driver refuse what D1 refuses, and normalizes what both accept to one shape.
33
+ * The one deliberate exception is `boolean`, which is coerced to 0/1 on both sides rather
34
+ * than refused on both: SQLite has no boolean type, so 0/1 is not a guess about intent,
35
+ * and refusing it would break every natural `where('disabled', '=', false)` in the fleet.
36
+ * `undefined` is refused, because "missing argument" and "intentional NULL" are genuinely
37
+ * different and only the caller knows which was meant.
38
+ *
39
+ * ## Transactions are refused on both sides, and `batch()` is the answer
40
+ *
41
+ * D1 has no interactive transaction — there is no `BEGIN`/`COMMIT` over HTTP, only
42
+ * {@link D1LikeDatabase.batch}, which is atomic and runs as one round trip. A local
43
+ * driver that happily honoured `BEGIN` would be the sync-vs-async trap a third time, so
44
+ * {@link D1UnsupportedError} is thrown by both. Wrap the statements in `batch()` instead;
45
+ * it is atomic on D1 and wrapped in a real `bun:sqlite` transaction locally, so the
46
+ * guarantee is the same.
47
+ *
48
+ * The shape below deliberately mirrors Cloudflare's own `D1Database` rather than
49
+ * inventing a vocabulary: the remote driver is then close to a pass-through, and the
50
+ * thing an app author reads in Cloudflare's docs is the thing they have.
51
+ */
52
+
53
+ /** A row as it comes back from either driver — column name to normalized value. */
54
+ export type D1LikeRow = Record<string, D1LikeValue>;
55
+
56
+ /**
57
+ * Every value either driver can RETURN, after normalization. Note there is no `boolean`
58
+ * and no `bigint`: SQLite stores neither, and a driver that invented one on read would
59
+ * disagree with the other driver on the way back in.
60
+ */
61
+ export type D1LikeValue = string | number | null | Uint8Array;
62
+
63
+ /**
64
+ * Every value either driver accepts as a BOUND parameter. `boolean` is accepted and
65
+ * coerced to 0/1; `bigint` is accepted within the safe-integer range and refused outside
66
+ * it (where it could not survive the round trip anyway). `undefined` is absent on
67
+ * purpose — see the header.
68
+ */
69
+ export type D1LikeBindable = string | number | boolean | bigint | null | Uint8Array | ArrayBuffer;
70
+
71
+ /**
72
+ * What a statement reports about its own execution. Mirrors D1's `meta`, with the fields
73
+ * the local driver can honestly fill. A field the local driver cannot know is `null`
74
+ * rather than a plausible-looking zero — a fabricated `rows_read` of 0 would read as a
75
+ * measurement in `192`'s CPU accounting rather than as an absence.
76
+ */
77
+ export interface D1LikeMeta {
78
+ /** Rows changed by the statement. `0` for a read. */
79
+ changes: number;
80
+ /** Rowid of the last insert, or `null` when the statement inserted nothing. */
81
+ last_row_id: number | null;
82
+ /** Wall-clock milliseconds the driver spent on the statement. */
83
+ duration: number;
84
+ /** D1 only — rows the query engine read. `null` locally, where nothing counts them. */
85
+ rows_read: number | null;
86
+ /** D1 only — rows the query engine wrote. `null` locally. */
87
+ rows_written: number | null;
88
+ }
89
+
90
+ /** The result of `.all()` / `.run()` on either driver. */
91
+ export interface D1LikeResult<T = D1LikeRow> {
92
+ results: T[];
93
+ success: true;
94
+ meta: D1LikeMeta;
95
+ }
96
+
97
+ /**
98
+ * A prepared statement. `bind()` returns a NEW statement rather than mutating this one,
99
+ * which is D1's own contract and what makes a statement safe to keep and re-bind.
100
+ */
101
+ export interface D1LikeStatement {
102
+ bind(...values: D1LikeBindable[]): D1LikeStatement;
103
+ /** The first row, or `null` when the query matched nothing. */
104
+ first<T = D1LikeRow>(): Promise<T | null>;
105
+ /** One column of the first row, or `null` when the query matched nothing. */
106
+ first<V = D1LikeValue>(column: string): Promise<V | null>;
107
+ all<T = D1LikeRow>(): Promise<D1LikeResult<T>>;
108
+ run(): Promise<D1LikeResult<never>>;
109
+ /** Rows as positional arrays rather than objects — the cheap shape for bulk reads. */
110
+ raw<V = D1LikeValue>(): Promise<V[][]>;
111
+ }
112
+
113
+ /**
114
+ * The database handle an app holds. One of these is constructed per request on a Worker
115
+ * and once per process locally; nothing below this line knows which it got.
116
+ */
117
+ export interface D1LikeDatabase {
118
+ prepare(sql: string): D1LikeStatement;
119
+ /**
120
+ * Run several statements atomically as ONE round trip. This is the transaction
121
+ * primitive — see the header for why there is no `begin()`.
122
+ */
123
+ batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]>;
124
+ /**
125
+ * Run one or more statements for their side effects, with no bound parameters —
126
+ * schema migrations, `PRAGMA`s, DDL. Not for anything carrying user input.
127
+ */
128
+ exec(sql: string): Promise<{ count: number; duration: number }>;
129
+ /** Which side of the seam this handle is. Lets a caller branch where it genuinely must. */
130
+ readonly flavor: 'local' | 'd1';
131
+ }
132
+
133
+ /** Thrown when a bound parameter is of a type D1 will not accept. */
134
+ export class D1BindError extends TypeError {
135
+ readonly index: number;
136
+ constructor(index: number, message: string) {
137
+ super(`parameter ${index + 1}: ${message}`);
138
+ this.name = 'D1BindError';
139
+ this.index = index;
140
+ }
141
+ }
142
+
143
+ /** Thrown for an operation D1 cannot perform, so the local driver refuses it too. */
144
+ export class D1UnsupportedError extends Error {
145
+ constructor(what: string, instead: string) {
146
+ super(`${what} is not supported on D1 — ${instead}`);
147
+ this.name = 'D1UnsupportedError';
148
+ }
149
+ }
150
+
151
+ /** Thrown when a statement would exceed one of D1's hard limits. See `./limits`. */
152
+ export class D1LimitError extends RangeError {
153
+ readonly limit: string;
154
+ readonly actual: number;
155
+ readonly max: number;
156
+ constructor(limit: string, actual: number, max: number, remedy: string) {
157
+ super(`D1 limit '${limit}' exceeded: ${actual} > ${max}. ${remedy}`);
158
+ this.name = 'D1LimitError';
159
+ this.limit = limit;
160
+ this.actual = actual;
161
+ this.max = max;
162
+ }
163
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The one place that decides what a bound parameter and a returned column MEAN, shared by
3
+ * both drivers.
4
+ *
5
+ * 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
6
+ * the local driver normalized its own way and the remote driver normalized its own way,
7
+ * the test in `sameShape.spec.ts` would be asserting that two independent implementations
8
+ * happen to agree today — which is a coincidence with a maintenance schedule. Here there
9
+ * is one implementation of the meaning and two implementations of the transport.
10
+ *
11
+ * Every rule below was measured against `bun:sqlite` on 2026-09-16 (`Bun 1.3`), not
12
+ * recalled: the probe wrote each type into a `CREATE TABLE t (v)` column and read it back.
13
+ */
14
+
15
+ import { D1BindError, type D1LikeBindable, type D1LikeMeta, type D1LikeValue } from './types';
16
+
17
+ /**
18
+ * The largest integer a JS number holds exactly. A `bigint` beyond this cannot survive the
19
+ * round trip through either driver — measured, `bun:sqlite` returns `9007199254740992` for
20
+ * a stored `9007199254740993` — so it is refused rather than silently rounded.
21
+ */
22
+ const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
23
+
24
+ /**
25
+ * Normalize one bound parameter to something BOTH drivers accept, or throw.
26
+ *
27
+ * The local driver is deliberately made as strict as D1 here. `bun:sqlite` accepts
28
+ * `undefined` (binding NULL) and `boolean` (coercing to 0/1) where D1 throws; a local
29
+ * driver that inherited that leniency would go green on this Mac and red in the Worker,
30
+ * which is the same defect as a sync seam.
31
+ */
32
+ export function normalizeBind(value: unknown, index: number): D1LikeBindable {
33
+ if (value === null) return null;
34
+
35
+ switch (typeof value) {
36
+ case 'string':
37
+ case 'number':
38
+ // 🔴 NaN and ±Infinity have no SQLite representation. `bun:sqlite` stores NaN as
39
+ // NULL; D1 rejects it. Refusing both is the only answer that reads the same.
40
+ if (typeof value === 'number' && !Number.isFinite(value)) {
41
+ throw new D1BindError(index, `${value} has no SQLite representation — store null, or a string`);
42
+ }
43
+ return value;
44
+
45
+ // SQLite has no boolean type: 0/1 is the storage, not a guess about intent. Coerced
46
+ // on BOTH sides so the two agree, rather than refused on both, which would break
47
+ // every natural `where('disabled', '=', false)` in the fleet.
48
+ case 'boolean':
49
+ return value ? 1 : 0;
50
+
51
+ case 'bigint':
52
+ if (value > SAFE || value < -SAFE) {
53
+ throw new D1BindError(
54
+ index,
55
+ `bigint ${value} is outside the safe-integer range and cannot round-trip — store it as TEXT`,
56
+ );
57
+ }
58
+ return Number(value);
59
+
60
+ case 'undefined':
61
+ // 🔴 The measured trap. `bun:sqlite` binds NULL and says nothing; D1 throws.
62
+ // "Missing argument" and "intentional NULL" are different, and only the caller
63
+ // knows which was meant — so the caller says so.
64
+ throw new D1BindError(index, 'undefined — pass null explicitly if you mean SQL NULL');
65
+
66
+ case 'object':
67
+ if (value instanceof Uint8Array) return value;
68
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
69
+ if (value instanceof Date) {
70
+ // Common enough to deserve its own sentence rather than "not supported".
71
+ throw new D1BindError(index, 'Date — store an ISO string (`d.toISOString()`) or an epoch number');
72
+ }
73
+ throw new D1BindError(index, `${value.constructor?.name ?? 'object'} — bind a string, number, null or Uint8Array`);
74
+
75
+ default:
76
+ throw new D1BindError(index, `${typeof value} is not bindable`);
77
+ }
78
+ }
79
+
80
+ /** Normalize a whole parameter list, reporting the offending position on failure. */
81
+ export const normalizeBinds = (values: readonly unknown[]): D1LikeBindable[] =>
82
+ values.map((v, i) => normalizeBind(v, i));
83
+
84
+ /**
85
+ * Normalize one column value coming BACK from a driver.
86
+ *
87
+ * The measured disagreement is BLOBs: `bun:sqlite` returns a `Uint8Array`, D1 returns a
88
+ * plain `number[]` (it crosses the wire as JSON). `Uint8Array` wins on both — it is the
89
+ * type every consumer of a blob actually wants, and `Array.isArray` is a reliable way to
90
+ * spot D1's shape because no other column type returns an array.
91
+ */
92
+ export function normalizeValue(value: unknown): D1LikeValue {
93
+ if (value === null || value === undefined) return null;
94
+ if (value instanceof Uint8Array) return value;
95
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
96
+ if (Array.isArray(value)) return Uint8Array.from(value as number[]);
97
+ if (typeof value === 'bigint') return Number(value);
98
+ if (typeof value === 'boolean') return value ? 1 : 0;
99
+ if (typeof value === 'string' || typeof value === 'number') return value;
100
+ // Nothing else can come out of either driver; if it does, that is a driver change we
101
+ // want to hear about loudly rather than pass through as an opaque object.
102
+ throw new TypeError(`unexpected column value of type ${typeof value} from the database driver`);
103
+ }
104
+
105
+ /** Normalize a row object, preserving column order. */
106
+ export function normalizeRow<T>(row: Record<string, unknown>): T {
107
+ const out: Record<string, D1LikeValue> = {};
108
+ for (const key of Object.keys(row)) out[key] = normalizeValue(row[key]);
109
+ return out as T;
110
+ }
111
+
112
+ /** Normalize a whole result set. */
113
+ export const normalizeRows = <T>(rows: readonly Record<string, unknown>[]): T[] =>
114
+ rows.map((r) => normalizeRow<T>(r));
115
+
116
+ /**
117
+ * Build a {@link D1LikeMeta}. `rows_read`/`rows_written` default to `null` — the local
118
+ * driver genuinely cannot count them, and a plausible-looking `0` would be read as a
119
+ * measurement by `192`'s CPU accounting rather than as an absence.
120
+ */
121
+ export function makeMeta(partial: {
122
+ changes?: number;
123
+ last_row_id?: number | bigint | null;
124
+ duration: number;
125
+ rows_read?: number | null;
126
+ rows_written?: number | null;
127
+ }): D1LikeMeta {
128
+ const raw = partial.last_row_id ?? null;
129
+ return {
130
+ changes: partial.changes ?? 0,
131
+ // A rowid of 0 means "nothing was inserted" in both drivers; report it as absent
132
+ // so a caller can `?? ` it rather than having to know that 0 is a sentinel.
133
+ last_row_id: raw === null || Number(raw) === 0 ? null : Number(raw),
134
+ duration: partial.duration,
135
+ rows_read: partial.rows_read ?? null,
136
+ rows_written: partial.rows_written ?? null,
137
+ };
138
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * 🔴 **The accounts model — a master password IS a tenant.**
3
+ *
4
+ * The owner's words, and the whole specification:
5
+ *
6
+ * > *"If I login with a master password I only see the files I added or removed with that
7
+ * > login. If I login with my other password I have not made yet I only see the other ones
8
+ * > I add in that login. The master passwords represent their own tenants or you can think
9
+ * > of it as a fully separate account even though I am the user in both cases."*
10
+ *
11
+ * This file proves the half that lives in the lock. The half that lives in an app — every
12
+ * query scoped by the id this hands out — is that app's own gate to prove.
13
+ *
14
+ * The claim each `describe` carries is the thing that would be catastrophic to get wrong,
15
+ * and three of them are catastrophic in a way that is SILENT: a tenant that cannot be
16
+ * reached looks exactly like a tenant with nothing in it.
17
+ */
18
+ import { beforeEach, describe, expect, test } from "bun:test";
19
+ import {
20
+ type MasterLockKdfParams,
21
+ deriveMasterLockVerifier,
22
+ } from "cursedbelt-core/master-lock";
23
+ import { FIRST_ACCOUNT_ID, MasterLock, readRecord } from "./masterLock";
24
+ import { createMemoryMasterLockStore } from "./store";
25
+
26
+ /** `iter: 1` — the ROUND COUNT is pinned in `cursedbelt-core`'s `kdf.spec.ts`; 600k × N
27
+ * argon2 verifies here would be minutes of gate for nothing this file is testing. */
28
+ const KDF: MasterLockKdfParams = {
29
+ v: 1,
30
+ alg: "PBKDF2-SHA256",
31
+ iter: 1,
32
+ salt: "AAECAwQFBgcICQoLDA0ODw",
33
+ };
34
+
35
+ const FIRST = "the owner's vault master password";
36
+ const SECOND = "password";
37
+
38
+ let firstVerifier = "";
39
+ let secondVerifier = "";
40
+ /** The LEGACY record: one password, no accounts — the shape every `MASTER_LOCK_SEED` in
41
+ * the fleet carries, and the shape every un-upgraded app already has in its database. */
42
+ let legacyJson = "";
43
+
44
+ beforeEach(async () => {
45
+ if (firstVerifier) return;
46
+ firstVerifier = await deriveMasterLockVerifier(FIRST, KDF);
47
+ secondVerifier = await deriveMasterLockVerifier(SECOND, KDF);
48
+ legacyJson = JSON.stringify({
49
+ kdf: KDF,
50
+ verifierHash: await Bun.password.hash(firstVerifier, { algorithm: "argon2id" }),
51
+ idleMs: 300_000,
52
+ });
53
+ });
54
+
55
+ const build = (stored?: string | null) => {
56
+ const store = createMemoryMasterLockStore(stored ?? null);
57
+ return { store, lock: new MasterLock({ store }) };
58
+ };
59
+
60
+ const req = (token: string): Request =>
61
+ new Request("http://app.test/", { headers: { cookie: `master_lock=${token}` } });
62
+
63
+ /** Unlock and hand back a request that presents the resulting token. */
64
+ async function open(lock: MasterLock, verifier: string) {
65
+ const result = await lock.unlock(verifier);
66
+ if (!result.ok) throw new Error("expected the unlock to succeed");
67
+ return { ...result, req: req(result.token) };
68
+ }
69
+
70
+ describe("🔴 claim 1 — the owner's existing password keeps opening everything he has", () => {
71
+ test("a legacy single-password record becomes acct-1, and that password still works", async () => {
72
+ // THE migration guarantee. `apps/collections` stamps its 279 existing rows with this
73
+ // id, so an id that moved — or a record that failed to parse and armed nothing —
74
+ // would orphan the entire library this migration exists to carry across.
75
+ const { lock } = build(legacyJson);
76
+ expect(lock.configured).toBe(true);
77
+ expect(lock.accounts.length).toBe(1);
78
+ expect(lock.accounts[0]?.id).toBe(FIRST_ACCOUNT_ID);
79
+
80
+ const opened = await open(lock, firstVerifier);
81
+ expect(opened.accountId).toBe(FIRST_ACCOUNT_ID);
82
+ // And nothing else was invented along the way.
83
+ expect(lock.idleMs).toBe(300_000);
84
+ });
85
+
86
+ test("the migration is a READ — it does not rewrite the row until something else does", async () => {
87
+ // A record that rewrote itself on boot would mean a rollback to the previous build
88
+ // could no longer read its own store. Nothing is persisted until a write happens for
89
+ // a reason of its own.
90
+ const { lock, store } = build(legacyJson);
91
+ expect(lock.configured).toBe(true);
92
+ expect(store.read()).toBe(legacyJson);
93
+
94
+ lock.setIdleMs(600_000);
95
+ const rewritten = readRecord(store.read());
96
+ expect(rewritten?.accounts[0]?.id).toBe(FIRST_ACCOUNT_ID);
97
+ expect(rewritten?.idleMs).toBe(600_000);
98
+ });
99
+ });
100
+
101
+ describe("🔴 claim 2 — a second password is a second tenant, and never the first one", () => {
102
+ test("adding one needs a live unlock AND the current password, re-typed", async () => {
103
+ const { lock } = build(legacyJson);
104
+
105
+ // No unlock at all.
106
+ expect(
107
+ await lock.addAccount(req("nope"), {
108
+ currentVerifier: firstVerifier,
109
+ verifier: secondVerifier,
110
+ label: "Account 2",
111
+ }),
112
+ ).toEqual({ ok: false, reason: "locked" });
113
+
114
+ const opened = await open(lock, firstVerifier);
115
+
116
+ // Unlocked, but the current password not proved.
117
+ expect(
118
+ await lock.addAccount(opened.req, {
119
+ currentVerifier: "not it",
120
+ verifier: secondVerifier,
121
+ label: "Account 2",
122
+ }),
123
+ ).toEqual({ ok: false, reason: "wrong" });
124
+ expect(lock.accounts.length).toBe(1);
125
+
126
+ const added = await lock.addAccount(opened.req, {
127
+ currentVerifier: firstVerifier,
128
+ verifier: secondVerifier,
129
+ label: "Account 2",
130
+ hint: "the obvious one",
131
+ });
132
+ expect(added.ok && added.account.id).toBe("acct-2");
133
+ });
134
+
135
+ test("each password opens its OWN account, and a token never reads as the other", async () => {
136
+ const { lock } = build(legacyJson);
137
+ const first = await open(lock, firstVerifier);
138
+ await lock.addAccount(first.req, {
139
+ currentVerifier: firstVerifier,
140
+ verifier: secondVerifier,
141
+ label: "Account 2",
142
+ });
143
+ const second = await open(lock, secondVerifier);
144
+
145
+ expect(first.accountId).toBe("acct-1");
146
+ expect(second.accountId).toBe("acct-2");
147
+ // The property everything else rests on: a token is bound to the account that minted
148
+ // it, so two browsers open at once are two tenants, not one ambient state.
149
+ expect(lock.accountFor(first.req)).toBe("acct-1");
150
+ expect(lock.accountFor(second.req)).toBe("acct-2");
151
+ });
152
+
153
+ test("creating a tenant does NOT enter it", async () => {
154
+ // Switching means typing that tenant's password. If creating one dropped you into
155
+ // it, the owner would add an account and watch his library apparently vanish.
156
+ const { lock } = build(legacyJson);
157
+ const first = await open(lock, firstVerifier);
158
+ await lock.addAccount(first.req, {
159
+ currentVerifier: firstVerifier,
160
+ verifier: secondVerifier,
161
+ label: "Account 2",
162
+ });
163
+ expect(lock.accountFor(first.req)).toBe("acct-1");
164
+ });
165
+
166
+ test("🔴 a password that already opens something is refused", async () => {
167
+ // Two accounts under one password would make `unlock` serve whichever the loop
168
+ // reached first — so the owner would type the password he has always typed and be
169
+ // handed an empty library, with his real one apparently gone.
170
+ const { lock } = build(legacyJson);
171
+ const first = await open(lock, firstVerifier);
172
+ expect(
173
+ await lock.addAccount(first.req, {
174
+ currentVerifier: firstVerifier,
175
+ verifier: firstVerifier,
176
+ label: "A clone",
177
+ }),
178
+ ).toEqual({ ok: false, reason: "duplicate" });
179
+ expect(lock.accounts.length).toBe(1);
180
+ });
181
+ });
182
+
183
+ describe("🔴 claim 3 — changing a password never changes which content it opens", () => {
184
+ test("the account id survives a rotation, and the siblings survive it too", async () => {
185
+ // The owner: *"If I change account 2 password it just changes how I get the access.
186
+ // For instance I could change it from 'password' to 'passwordIPicked' later and I
187
+ // would still access the same content afterward."*
188
+ const { lock } = build(legacyJson);
189
+ const first = await open(lock, firstVerifier);
190
+ await lock.addAccount(first.req, {
191
+ currentVerifier: firstVerifier,
192
+ verifier: secondVerifier,
193
+ label: "Account 2",
194
+ });
195
+
196
+ const second = await open(lock, secondVerifier);
197
+ const picked = await deriveMasterLockVerifier("passwordIPicked", KDF);
198
+ expect(
199
+ await lock.change(second.req, { currentVerifier: secondVerifier, verifier: picked }),
200
+ ).toEqual({ ok: true });
201
+
202
+ // The new password opens the SAME tenant…
203
+ const reopened = await open(lock, picked);
204
+ expect(reopened.accountId).toBe("acct-2");
205
+ // …the old one opens nothing…
206
+ expect((await lock.unlock(secondVerifier)).ok).toBe(false);
207
+ // …and account 1 is untouched, which is what a shared KDF salt is protecting.
208
+ const stillFirst = await open(lock, firstVerifier);
209
+ expect(stillFirst.accountId).toBe("acct-1");
210
+ });
211
+
212
+ test("a rotation cannot collide a password onto a sibling", async () => {
213
+ const { lock } = build(legacyJson);
214
+ const first = await open(lock, firstVerifier);
215
+ await lock.addAccount(first.req, {
216
+ currentVerifier: firstVerifier,
217
+ verifier: secondVerifier,
218
+ label: "Account 2",
219
+ });
220
+ const second = await open(lock, secondVerifier);
221
+ expect(
222
+ await lock.change(second.req, {
223
+ currentVerifier: secondVerifier,
224
+ verifier: firstVerifier,
225
+ }),
226
+ ).toEqual({ ok: false, reason: "invalid" });
227
+ });
228
+ });
229
+
230
+ describe("🔴 claim 4 — the hints are readable while locked, and nothing else is", () => {
231
+ test("a hint survives a rotation and can be rewritten without the password", async () => {
232
+ const { lock } = build(legacyJson);
233
+ const first = await open(lock, firstVerifier);
234
+ await lock.addAccount(first.req, {
235
+ currentVerifier: firstVerifier,
236
+ verifier: secondVerifier,
237
+ label: "Account 2",
238
+ hint: "the obvious one",
239
+ });
240
+
241
+ expect(lock.hints()).toEqual([
242
+ { label: "Account 1", hint: "" },
243
+ { label: "Account 2", hint: "the obvious one" },
244
+ ]);
245
+
246
+ const second = await open(lock, secondVerifier);
247
+ expect(lock.editAccount(second.req, { id: "acct-2", hint: "rhymes with lassword" })).toBe(true);
248
+ expect(lock.hints()[1]?.hint).toBe("rhymes with lassword");
249
+ });
250
+
251
+ test("🔴 one session cannot rewrite another tenant's label or hint", async () => {
252
+ // The hint is the one field designed to be read by somebody who is LOCKED OUT, so a
253
+ // session holding one password must not be able to rewrite the note that gets the
254
+ // other one back in.
255
+ const { lock } = build(legacyJson);
256
+ const first = await open(lock, firstVerifier);
257
+ await lock.addAccount(first.req, {
258
+ currentVerifier: firstVerifier,
259
+ verifier: secondVerifier,
260
+ label: "Account 2",
261
+ hint: "the obvious one",
262
+ });
263
+ expect(lock.editAccount(first.req, { id: "acct-2", hint: "overwritten" })).toBe(false);
264
+ expect(lock.hints()[1]?.hint).toBe("the obvious one");
265
+ });
266
+
267
+ test("a hint is trimmed and capped, so it cannot become a wall of text on the lock screen", async () => {
268
+ const { lock } = build(legacyJson);
269
+ const first = await open(lock, firstVerifier);
270
+ await lock.addAccount(first.req, {
271
+ currentVerifier: firstVerifier,
272
+ verifier: secondVerifier,
273
+ label: " Account\n\n2 ",
274
+ hint: `${"x".repeat(400)}`,
275
+ });
276
+ expect(lock.accounts[1]?.label).toBe("Account 2");
277
+ expect(lock.accounts[1]?.hint.length).toBe(200);
278
+ });
279
+ });
280
+
281
+ describe("🔴 claim 5 — a malformed row costs one account, never the keyring", () => {
282
+ test("an unreadable account is dropped and the others still open", async () => {
283
+ // This record is the ONLY thing that says which password opens which library.
284
+ // Refusing the whole file over one bad row would take every tenant down with it.
285
+ const good = await Bun.password.hash(firstVerifier, { algorithm: "argon2id" });
286
+ const stored = JSON.stringify({
287
+ kdf: KDF,
288
+ idleMs: 300_000,
289
+ accounts: [
290
+ { id: "acct-1", label: "Account 1", hint: "", verifierHash: good, createdAt: 1 },
291
+ { id: "acct-2", label: "Broken", hint: "", verifierHash: "not-a-hash", createdAt: 2 },
292
+ // A duplicate id, and an id shaped like something that could reach SQL.
293
+ { id: "acct-1", label: "Dupe", hint: "", verifierHash: good, createdAt: 3 },
294
+ { id: "../escape", label: "Nope", hint: "", verifierHash: good, createdAt: 4 },
295
+ ],
296
+ });
297
+ const { lock } = build(stored);
298
+ expect(lock.accounts.map((account) => account.id)).toEqual(["acct-1"]);
299
+ expect((await open(lock, firstVerifier)).accountId).toBe("acct-1");
300
+ });
301
+
302
+ test("an empty keyring is a legitimate state, not a parse failure", async () => {
303
+ const { lock } = build(JSON.stringify({ kdf: KDF, idleMs: 300_000, accounts: [] }));
304
+ expect(lock.configured).toBe(false);
305
+ expect(lock.awaitingEnrollment).toBe(true);
306
+ expect(lock.unlocked).toBe(false);
307
+ });
308
+ });