cursedbelt-server 2.1.0 → 3.0.1

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.
@@ -112,12 +112,22 @@ describe("claim 1 — one person's unlock never opens another's", () => {
112
112
  await enroll(OWNER, "the owner's own password");
113
113
  const guestPassword = "a different password entirely";
114
114
  await enroll(GUEST, guestPassword);
115
- const kdf = newMasterLockKdfParams();
116
- const rotated = await directory.for(OWNER).change({
117
- currentVerifier: await deriveMasterLockVerifier("the owner's own password", directory.for(OWNER).kdf as MasterLockKdfParams),
118
- kdf,
119
- verifier: await deriveMasterLockVerifier("something new", kdf),
120
- });
115
+ // The rotation needs the owner's OWN live unlock — an open session is not proof
116
+ // somebody is at the keyboard, and per person it is not even proof WHICH person.
117
+ const ownerKdf = directory.for(OWNER).kdf as MasterLockKdfParams;
118
+ const ownerVerifier = await deriveMasterLockVerifier("the owner's own password", ownerKdf);
119
+ directory.for(OWNER).lock();
120
+ const ownerOpen = await directory.for(OWNER).unlock(ownerVerifier);
121
+ expect(ownerOpen.ok).toBe(true);
122
+ const rotated = await directory.for(OWNER).change(
123
+ new Request("https://orch.example/", {
124
+ headers: { cookie: `master_lock=${ownerOpen.ok ? ownerOpen.token : ""}` },
125
+ }),
126
+ {
127
+ currentVerifier: ownerVerifier,
128
+ verifier: await deriveMasterLockVerifier("something new", ownerKdf),
129
+ },
130
+ );
121
131
  expect(rotated.ok).toBe(true);
122
132
  // The guest's still opens on the guest's unchanged password.
123
133
  directory.for(GUEST).lock();
@@ -191,11 +201,17 @@ describe("claim 2 — signed in is never enough", () => {
191
201
  expect(fresh.awaitingEnrollment).toBe(true);
192
202
  });
193
203
 
194
- test("the app-wide mode keeps its opposite default, so collections is untouched", () => {
204
+ test("🔴 the app-wide default now MATCHES it — there are not two answers any more", () => {
205
+ // This test used to assert the opposite, and the two modes disagreeing was the whole
206
+ // reason `enrollable` existed. The accounts model settles it in the per-person
207
+ // direction for every app, on the owner's ruling: *"if there is no master password
208
+ // then it has to get one set before a user could add anything."* Nothing is bricked,
209
+ // because enrollment is the way out of the state in both cases — which is exactly
210
+ // what made failing closed affordable here and unaffordable there.
195
211
  const appWide = new MasterLock({ store: createMemoryMasterLockStore(null) });
196
212
  expect(appWide.configured).toBe(false);
197
- expect(appWide.unlocked).toBe(true);
198
- expect(appWide.awaitingEnrollment).toBe(false);
213
+ expect(appWide.unlocked).toBe(false);
214
+ expect(appWide.awaitingEnrollment).toBe(true);
199
215
  });
200
216
 
201
217
  test("the guard refuses every app path for an un-enrolled principal", async () => {
@@ -246,21 +262,35 @@ describe("claim 3 — enroll only ever creates the password that was never set",
246
262
  const key = "shared";
247
263
  return { read: () => kvv.get(key), write: (json) => kvv.set(key, json) };
248
264
  })();
249
- const a = new MasterLock({ store, enrollable: true });
250
- const b = new MasterLock({ store, enrollable: true });
265
+ const a = new MasterLock({ store });
266
+ const b = new MasterLock({ store });
251
267
  const first = await a.enroll({ kdf: KDF, verifier: await deriveMasterLockVerifier("first", KDF) });
252
268
  expect(first.ok).toBe(true);
253
269
  const second = await b.enroll({ kdf: KDF, verifier: await deriveMasterLockVerifier("second", KDF) });
254
270
  expect(second).toEqual({ ok: false, reason: "already-configured" });
255
271
  });
256
272
 
257
- test("🔴 it refuses outside per-person mode, so no app-wide lock can be armed by a POST", async () => {
273
+ test("🔴 an app-wide lock with no account CAN now be armed by a POST — and that is the point", async () => {
274
+ // The inverse of what this asserted before the accounts model. Enrollment used to be
275
+ // refused outside per-person mode so that mounting the wall could not silently arm a
276
+ // lock the owner had not chosen; the owner has since ruled that an app with no master
277
+ // password must take one before it serves anything, which makes this route the ONLY
278
+ // way out of that state. It is not a hole for the reason the engine's note gives at
279
+ // length: whoever reaches it already holds a live session for an account the app
280
+ // admits, and it refuses the moment an account exists.
258
281
  const appWide = new MasterLock({ store: createMemoryMasterLockStore(null) });
259
- const result = await appWide.enroll({
282
+ const first = await appWide.enroll({
260
283
  kdf: KDF,
261
284
  verifier: await deriveMasterLockVerifier("anything", KDF),
262
285
  });
263
- expect(result).toEqual({ ok: false, reason: "not-enrollable" });
286
+ expect(first.ok && first.accountId).toBe("acct-1");
287
+
288
+ // And it can only ever CREATE the password that was never set.
289
+ const again = await appWide.enroll({
290
+ kdf: KDF,
291
+ verifier: await deriveMasterLockVerifier("something else", KDF),
292
+ });
293
+ expect(again).toEqual({ ok: false, reason: "already-configured" });
264
294
  });
265
295
 
266
296
  test("a malformed submission is refused rather than stored", async () => {
@@ -373,6 +403,6 @@ describe("claim 4 — nothing a caller sends chooses whose lock they meet", () =
373
403
  expect(key).toBe(masterLockPrincipalKey(OWNER));
374
404
  expect(value).not.toContain(OWNER);
375
405
  expect(value).not.toContain("the owner's own password");
376
- expect(JSON.parse(value).verifierHash.startsWith("$argon2")).toBe(true);
406
+ expect(JSON.parse(value).accounts[0].verifierHash.startsWith("$argon2")).toBe(true);
377
407
  });
378
408
  });
@@ -38,6 +38,12 @@
38
38
  * an env var that pre-set someone's password would be exactly the "one credential opens
39
39
  * everybody" shape this replaces. A principal with no record is `awaitingEnrollment` —
40
40
  * locked, and offered the choose-a-password form.
41
+ *
42
+ * 🔴 That last sentence used to need an `enrollable: true` flag, and does not any more.
43
+ * Failing closed with an enrollment form is now what EVERY lock does when it holds no
44
+ * account (see `masterLock.ts`'s `unlocked`), so the flag that used to separate the two
45
+ * modes has nothing left to decide. What still makes this file per-PERSON is the key each
46
+ * record is stored under, which is the only thing it ever really was.
41
47
  */
42
48
  import { createHash } from "node:crypto";
43
49
  import { MasterLock, type MasterLockStore, readRecord } from "./masterLock";
@@ -105,7 +111,6 @@ export class MasterLockDirectory {
105
111
  };
106
112
  const lock = new MasterLock({
107
113
  store,
108
- enrollable: true,
109
114
  ...(this.options.now ? { now: this.options.now } : {}),
110
115
  ...(this.options.log
111
116
  ? { log: (line: string) => this.options.log?.(`[${principalId}] ${line}`) }
@@ -34,7 +34,12 @@ describe("mintMasterLockSeed", () => {
34
34
  const a = await mintMasterLockSeed("stage-password-1");
35
35
  const b = await mintMasterLockSeed("stage-password-1");
36
36
  expect(a.kdf.salt).not.toBe(b.kdf.salt);
37
- expect(a.verifierHash).not.toBe(b.verifierHash);
37
+ expect(a.accounts[0]?.verifierHash).not.toBe(b.accounts[0]?.verifierHash);
38
+ // 🔴 Both mints name the SAME account id, and that is deliberate rather than an
39
+ // oversight: a seed is what an app boots with before anyone has typed anything, so
40
+ // the tenant it names has to be the tenant whose rows the app already holds.
41
+ expect(a.accounts[0]?.id).toBe("acct-1");
42
+ expect(b.accounts[0]?.id).toBe("acct-1");
38
43
  });
39
44
 
40
45
  it("refuses a password short enough to make the lock decorative", async () => {
@@ -50,7 +55,7 @@ describe("serializeMasterLockSeed", () => {
50
55
  const env = serializeMasterLockSeed(record);
51
56
  expect(env.startsWith("'")).toBe(true);
52
57
  expect(env.endsWith("'")).toBe(true);
53
- expect(readRecord(env)?.verifierHash).toBe(record.verifierHash);
58
+ expect(readRecord(env)?.accounts[0]?.verifierHash).toBe(record.accounts[0]?.verifierHash);
54
59
  });
55
60
  });
56
61
 
@@ -28,7 +28,7 @@ import {
28
28
  deriveMasterLockVerifier,
29
29
  newMasterLockKdfParams,
30
30
  } from "cursedbelt-core/master-lock/kdf";
31
- import type { MasterLockRecord } from "./masterLock";
31
+ import { FIRST_ACCOUNT_ID, type MasterLockRecord } from "./masterLock";
32
32
 
33
33
  /**
34
34
  * The idle window a freshly minted lock starts with.
@@ -48,6 +48,10 @@ export interface MintMasterLockSeedOptions {
48
48
  idleMs?: number;
49
49
  /** Reuse existing params instead of generating a salt — for a rotation. */
50
50
  kdf?: MasterLockKdfParams;
51
+ /** What to call the tenant this seed creates. Defaults to `Account 1`. */
52
+ label?: string;
53
+ /** The owner's reminder for this password, shown on the lock page's "I forgot". */
54
+ hint?: string;
51
55
  }
52
56
 
53
57
  /**
@@ -69,8 +73,24 @@ export async function mintMasterLockSeed(
69
73
  const verifier = await deriveMasterLockVerifier(password, kdf);
70
74
  return {
71
75
  kdf,
72
- verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
73
76
  idleMs: options.idleMs ?? DEFAULT_SEED_IDLE_MS,
77
+ /*
78
+ * 🔴 ONE account, and its id is `FIRST_ACCOUNT_ID` rather than anything derived
79
+ * from `options`. A seed is what an app boots with before anybody has typed
80
+ * anything, so the tenant it names is the tenant whose rows the app already holds —
81
+ * and `readRecord` maps the legacy single-password shape onto the same id for
82
+ * exactly that reason. Two different "first" ids would give a seeded app and a
83
+ * migrated app different answers to "whose library is this".
84
+ */
85
+ accounts: [
86
+ {
87
+ id: FIRST_ACCOUNT_ID,
88
+ label: options.label ?? "Account 1",
89
+ hint: options.hint ?? "",
90
+ verifierHash: await Bun.password.hash(verifier, { algorithm: "argon2id" }),
91
+ createdAt: 0,
92
+ },
93
+ ],
74
94
  };
75
95
  }
76
96