@aooth/user 0.1.3 → 0.1.5

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.
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_user_store = require("./user-store-CdWrTeqR.cjs");
2
+ const require_user_store = require("./user-store-BPZVAboN.cjs");
3
3
  //#region src/atscript-db/index.ts
4
4
  function isConflict(err) {
5
5
  return typeof err === "object" && err !== null && err.code === "CONFLICT";
@@ -34,11 +34,35 @@ var UsersStoreAtscriptDb = class extends require_user_store.UserStore {
34
34
  if (update.set) Object.assign(patch, update.set);
35
35
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.setAtPath(patch, path, { $inc: amount });
36
36
  if (Object.keys(patch).length <= 1) return true;
37
+ if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
37
38
  return (await this.table.updateOne(patch)).matchedCount > 0;
38
39
  }
39
40
  async delete(username) {
40
41
  return (await this.table.deleteMany({ username })).deletedCount > 0;
41
42
  }
43
+ /**
44
+ * Inline retry loop rather than delegating to @atscript/db's
45
+ * `withOptimisticRetry`: that helper expects the mutator to always return a
46
+ * patch object, but our contract lets the mutator return `null` (the
47
+ * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
48
+ * Bridging would need a sentinel exception. The version-bump + $cas
49
+ * atomicity still happen at the atscript-db table layer via the
50
+ * `expectedVersion` we thread through `update()`.
51
+ */
52
+ async withCas(username, mutator, opts) {
53
+ const maxAttempts = opts?.maxAttempts ?? 2;
54
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
55
+ const current = await this.findByUsername(username);
56
+ if (!current) throw new require_user_store.UserAuthError("NOT_FOUND");
57
+ const patch = mutator(current);
58
+ if (patch === null) return;
59
+ if (await this.update(username, {
60
+ ...patch,
61
+ expectedVersion: current.version ?? 0
62
+ })) return;
63
+ }
64
+ throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
65
+ }
42
66
  };
43
67
  //#endregion
44
68
  exports.UsersStoreAtscriptDb = UsersStoreAtscriptDb;
@@ -1,4 +1,4 @@
1
- import { C as UserStoreUpdate, t as UserStore, x as UserCredentials } from "./user-store-VJPWNgdp.cjs";
1
+ import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-B3EStUfT.cjs";
2
2
 
3
3
  //#region src/atscript-db/index.d.ts
4
4
  /**
@@ -50,6 +50,16 @@ declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends
50
50
  create(data: UserCredentials & TUserCustom): Promise<void>;
51
51
  update(username: string, update: UserStoreUpdate): Promise<boolean>;
52
52
  delete(username: string): Promise<boolean>;
53
+ /**
54
+ * Inline retry loop rather than delegating to @atscript/db's
55
+ * `withOptimisticRetry`: that helper expects the mutator to always return a
56
+ * patch object, but our contract lets the mutator return `null` (the
57
+ * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
58
+ * Bridging would need a sentinel exception. The version-bump + $cas
59
+ * atomicity still happen at the atscript-db table layer via the
60
+ * `expectedVersion` we thread through `update()`.
61
+ */
62
+ withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
53
63
  }
54
64
  //#endregion
55
65
  export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
@@ -1,4 +1,4 @@
1
- import { C as UserStoreUpdate, t as UserStore, x as UserCredentials } from "./user-store-Dbc9unW3.mjs";
1
+ import { S as UserCredentials, n as WithCasOptions, t as UserStore, w as UserStoreUpdate } from "./user-store-C1lxahSB.mjs";
2
2
 
3
3
  //#region src/atscript-db/index.d.ts
4
4
  /**
@@ -50,6 +50,16 @@ declare class UsersStoreAtscriptDb<TUserCustom extends object = object> extends
50
50
  create(data: UserCredentials & TUserCustom): Promise<void>;
51
51
  update(username: string, update: UserStoreUpdate): Promise<boolean>;
52
52
  delete(username: string): Promise<boolean>;
53
+ /**
54
+ * Inline retry loop rather than delegating to @atscript/db's
55
+ * `withOptimisticRetry`: that helper expects the mutator to always return a
56
+ * patch object, but our contract lets the mutator return `null` (the
57
+ * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
58
+ * Bridging would need a sentinel exception. The version-bump + $cas
59
+ * atomicity still happen at the atscript-db table layer via the
60
+ * `expectedVersion` we thread through `update()`.
61
+ */
62
+ withCas(username: string, mutator: (current: UserCredentials & TUserCustom) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
53
63
  }
54
64
  //#endregion
55
65
  export { AuthUserTable, UserCredentialsRow, UsersStoreAtscriptDb };
@@ -1,4 +1,4 @@
1
- import { c as setAtPath, l as UserAuthError, t as UserStore } from "./user-store-B_l9vqlQ.mjs";
1
+ import { c as setAtPath, l as UserAuthError, t as UserStore } from "./user-store-BaBmH13V.mjs";
2
2
  //#region src/atscript-db/index.ts
3
3
  function isConflict(err) {
4
4
  return typeof err === "object" && err !== null && err.code === "CONFLICT";
@@ -33,11 +33,35 @@ var UsersStoreAtscriptDb = class extends UserStore {
33
33
  if (update.set) Object.assign(patch, update.set);
34
34
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) setAtPath(patch, path, { $inc: amount });
35
35
  if (Object.keys(patch).length <= 1) return true;
36
+ if (update.expectedVersion !== void 0) patch.$cas = { version: update.expectedVersion };
36
37
  return (await this.table.updateOne(patch)).matchedCount > 0;
37
38
  }
38
39
  async delete(username) {
39
40
  return (await this.table.deleteMany({ username })).deletedCount > 0;
40
41
  }
42
+ /**
43
+ * Inline retry loop rather than delegating to @atscript/db's
44
+ * `withOptimisticRetry`: that helper expects the mutator to always return a
45
+ * patch object, but our contract lets the mutator return `null` (the
46
+ * race-loser detects nothing to do — see `UserService.consumeBackupCode`).
47
+ * Bridging would need a sentinel exception. The version-bump + $cas
48
+ * atomicity still happen at the atscript-db table layer via the
49
+ * `expectedVersion` we thread through `update()`.
50
+ */
51
+ async withCas(username, mutator, opts) {
52
+ const maxAttempts = opts?.maxAttempts ?? 2;
53
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
54
+ const current = await this.findByUsername(username);
55
+ if (!current) throw new UserAuthError("NOT_FOUND");
56
+ const patch = mutator(current);
57
+ if (patch === null) return;
58
+ if (await this.update(username, {
59
+ ...patch,
60
+ expectedVersion: current.version ?? 0
61
+ })) return;
62
+ }
63
+ throw new UserAuthError("CAS_EXHAUSTED");
64
+ }
41
65
  };
42
66
  //#endregion
43
67
  export { UsersStoreAtscriptDb };
package/dist/index.cjs CHANGED
@@ -1,7 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_user_store = require("./user-store-CdWrTeqR.cjs");
2
+ const require_user_store = require("./user-store-BPZVAboN.cjs");
3
3
  let node_crypto = require("node:crypto");
4
- let _prostojs_ftring = require("@prostojs/ftring");
5
4
  //#region src/mfa/backup-codes.ts
6
5
  /**
7
6
  * Custom alphabet for backup codes — uppercase letters and digits with the
@@ -146,6 +145,11 @@ function generateTotpCode(secret, config) {
146
145
  const counter = Math.floor(now / 1e3 / period);
147
146
  return hotpCode(decode(secret), counter, digits);
148
147
  }
148
+ /**
149
+ * Returns the matched HOTP counter when `code` is valid within the verification
150
+ * window, otherwise `null`. Returning the counter (not a bool) lets the caller
151
+ * persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
152
+ */
149
153
  function verifyTotpCode(secret, code, config) {
150
154
  const period = config?.period ?? 30;
151
155
  const digits = config?.digits ?? 6;
@@ -153,14 +157,15 @@ function verifyTotpCode(secret, code, config) {
153
157
  const now = (config?.clock ?? Date.now)();
154
158
  const counter = Math.floor(now / 1e3 / period);
155
159
  const key = decode(secret);
156
- if (typeof code !== "string" || code.length !== digits) return false;
160
+ if (typeof code !== "string" || code.length !== digits) return null;
157
161
  const submitted = Buffer.from(code, "utf8");
158
- let matched = false;
162
+ let matchedCounter = null;
159
163
  for (let i = -window; i <= window; i++) {
160
- const expected = Buffer.from(hotpCode(key, counter + i, digits), "utf8");
161
- if (expected.length === submitted.length && (0, node_crypto.timingSafeEqual)(expected, submitted)) matched = true;
164
+ const stepCounter = counter + i;
165
+ const expected = Buffer.from(hotpCode(key, stepCounter, digits), "utf8");
166
+ if (expected.length === submitted.length && (0, node_crypto.timingSafeEqual)(expected, submitted)) matchedCounter = stepCounter;
162
167
  }
163
- return matched;
168
+ return matchedCounter;
164
169
  }
165
170
  function generateMfaCode(length = 6) {
166
171
  return require_user_store.generateSecureRandom(length, "0123456789");
@@ -269,33 +274,57 @@ var PasswordHasher = class {
269
274
  };
270
275
  //#endregion
271
276
  //#region src/password/policy.ts
272
- const fnPool = new _prostojs_ftring.FtringsPool();
273
277
  function normalizePolicies(policies) {
274
278
  return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
275
279
  }
280
+ /**
281
+ * Helper that builds a `PasswordPolicyDef` from a real backend function plus
282
+ * its bound positional arguments. The function runs directly on the server
283
+ * (no sandbox, no eval); `serialized` is auto-derived as
284
+ * `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
285
+ * so the same constraint can ship to the frontend without re-implementing it.
286
+ *
287
+ * Bundler-safe: positional invocation means renamed parameters inside the
288
+ * function body stay consistent — the call site only relies on argument
289
+ * ORDER, not identifier preservation.
290
+ *
291
+ * Constraint on the rule body: it MUST reference only its own parameters.
292
+ * No closures over module imports, no `this`, no helpers from outer scope —
293
+ * `rule.toString()` ships the literal source; any free identifier the
294
+ * frontend cannot resolve breaks transferability. (`String.prototype.match`,
295
+ * regex literals, plain JS globals are fine.)
296
+ *
297
+ * Omit `args` to mark the policy backend-only — `serialized` stays
298
+ * `undefined` and `transferable` is false; frontend pre-validation skips it
299
+ * and only the server check enforces the rule.
300
+ */
301
+ function definePasswordPolicy(opts) {
302
+ const { rule, args, description, errorMessage } = opts;
303
+ const def = { rule: args ? (v) => rule(v, ...args) : (v) => rule(v, ...[]) };
304
+ if (args) {
305
+ const argList = args.map((a) => JSON.stringify(a)).join(", ");
306
+ def.serialized = argList.length > 0 ? `(v) => (${rule.toString()})(v, ${argList})` : `(v) => (${rule.toString()})(v)`;
307
+ }
308
+ if (description !== void 0) def.description = description;
309
+ if (errorMessage !== void 0) def.errorMessage = errorMessage;
310
+ return def;
311
+ }
276
312
  var PasswordPolicy = class {
277
313
  rule;
314
+ serialized;
278
315
  description;
279
316
  errorMessage;
280
317
  constructor(config) {
281
318
  this.rule = config.rule;
319
+ if (config.serialized !== void 0) this.serialized = config.serialized;
282
320
  this.description = config.description || "";
283
321
  this.errorMessage = config.errorMessage || "";
284
322
  }
285
- _evalFn;
286
323
  evaluate(password, context) {
287
- if (!this._evalFn) if (typeof this.rule === "function") this._evalFn = this.rule;
288
- else if (typeof this.rule === "string" && this.rule) {
289
- const fn = fnPool.getFn(this.rule);
290
- this._evalFn = (v, ctx) => fn({
291
- v,
292
- context: ctx
293
- });
294
- } else this._evalFn = () => true;
295
- return this._evalFn(password, context);
324
+ return this.rule(password, context);
296
325
  }
297
326
  get transferable() {
298
- return typeof this.rule === "string";
327
+ return this.serialized !== void 0;
299
328
  }
300
329
  };
301
330
  //#endregion
@@ -426,7 +455,7 @@ var UserService = class {
426
455
  /**
427
456
  * Hard-delete the user row. Returns nothing on success. Throws
428
457
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
429
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
458
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
430
459
  */
431
460
  async deleteUser(username) {
432
461
  if (!await this.store.delete(username)) throw new require_user_store.UserAuthError("NOT_FOUND");
@@ -499,30 +528,32 @@ var UserService = class {
499
528
  }
500
529
  getTransferablePolicies() {
501
530
  return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
502
- rule: p.rule,
531
+ rule: p.serialized,
503
532
  description: p.description,
504
533
  errorMessage: p.errorMessage
505
534
  }));
506
535
  }
507
536
  async addMfaMethod(username, method) {
508
- const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
509
- await this.store.update(username, { set: { mfa: { methods } } });
537
+ await this.store.withCas(username, (user) => {
538
+ return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
539
+ });
510
540
  }
511
541
  async confirmMfaMethod(username, name) {
512
- const user = await this.getUser(username);
513
- let found = false;
514
- const methods = user.mfa.methods.map((m) => {
515
- if (m.name === name) {
516
- found = true;
517
- return {
518
- ...m,
519
- confirmed: true
520
- };
521
- }
522
- return m;
542
+ await this.store.withCas(username, (user) => {
543
+ let found = false;
544
+ const methods = user.mfa.methods.map((m) => {
545
+ if (m.name === name) {
546
+ found = true;
547
+ return {
548
+ ...m,
549
+ confirmed: true
550
+ };
551
+ }
552
+ return m;
553
+ });
554
+ if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
555
+ return { set: { mfa: { methods } } };
523
556
  });
524
- if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
525
- await this.store.update(username, { set: { mfa: { methods } } });
526
557
  }
527
558
  async removeMfaMethod(username, name) {
528
559
  const user = await this.getUser(username);
@@ -565,24 +596,27 @@ var UserService = class {
565
596
  /**
566
597
  * Consume a backup code: returns `true` and removes the matching hash
567
598
  * from storage if `code` matches a stored backup code; returns `false`
568
- * if no match (without modifying storage).
569
- *
570
- * Read-then-write is not atomic at this layer: two concurrent consumes
571
- * of the same code may both succeed, with the last write winning. The
572
- * underlying store API does not expose an atomic match-and-remove, so
573
- * this is acceptable at the intended scale (backup codes are a fallback
574
- * path, not a hot one). Wrap in your store's transaction primitive if a
575
- * stricter guarantee is required.
599
+ * if no match (without modifying storage). Single-use is enforced by
600
+ * optimistic-concurrency CAS on the version column — concurrent consumes
601
+ * of the same code race fairly and only one wins; the loser re-reads,
602
+ * finds the hash already removed, and returns `false`.
576
603
  *
577
604
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
605
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
578
606
  */
579
607
  async consumeBackupCode(username, code) {
580
- const hashes = (await this.getUser(username)).backupCodes ?? [];
581
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
582
- if (idx < 0) return false;
583
- const remaining = hashes.filter((_, i) => i !== idx);
584
- await this.store.update(username, { set: { backupCodes: remaining } });
585
- return true;
608
+ let consumed = false;
609
+ await this.store.withCas(username, (user) => {
610
+ const hashes = user.backupCodes ?? [];
611
+ const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
612
+ if (idx < 0) {
613
+ consumed = false;
614
+ return null;
615
+ }
616
+ consumed = true;
617
+ return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
618
+ });
619
+ return consumed;
586
620
  }
587
621
  /**
588
622
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
@@ -596,12 +630,43 @@ var UserService = class {
596
630
  await this.ensureNotLockedOrThrow(username, user.account);
597
631
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
598
632
  if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
599
- if (verifyTotpCode(totp.value, code, config)) {
600
- if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
601
- return;
633
+ const matchedCounter = verifyTotpCode(totp.value, code, config);
634
+ const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
635
+ if (matchedCounter !== null && !isReplay) {
636
+ let replayDuringCas = false;
637
+ await this.store.withCas(username, (current) => {
638
+ const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
639
+ if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
640
+ replayDuringCas = true;
641
+ return null;
642
+ }
643
+ const set = { mfa: { methods: current.mfa.methods.map((m) => m.name === "totp" && m.confirmed ? {
644
+ ...m,
645
+ lastUsedWindow: matchedCounter
646
+ } : m) } };
647
+ if (current.account.failedLoginAttempts > 0) set.account = { failedLoginAttempts: 0 };
648
+ return { set };
649
+ });
650
+ if (!replayDuringCas) return;
602
651
  }
603
652
  await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
604
653
  }
654
+ /**
655
+ * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
656
+ * enrollment. Differs from `verifyMfa`:
657
+ * - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
658
+ * - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
659
+ * pre-activation; lockout doesn't apply).
660
+ * - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
661
+ *
662
+ * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
663
+ * totp method; MFA_INVALID on wrong code.
664
+ */
665
+ async verifyTotpSetupCode(username, code, config) {
666
+ const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
667
+ if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
668
+ if (verifyTotpCode(totp.value, code, config) === null) throw new require_user_store.UserAuthError("MFA_INVALID");
669
+ }
605
670
  getPasswordHasher() {
606
671
  return this.hasher;
607
672
  }
@@ -631,8 +696,9 @@ var UserService = class {
631
696
  * merge strategy replace the whole array.
632
697
  */
633
698
  async addTrustedDevice(username, record) {
634
- const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
635
- await this.store.update(username, { set: { trustedDevices: next } });
699
+ await this.store.withCas(username, (user) => {
700
+ return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
701
+ });
636
702
  }
637
703
  /**
638
704
  * Returns true when the supplied token (a) signs against the user+ip with
@@ -759,48 +825,72 @@ var UserStoreMemory = class extends require_user_store.UserStore {
759
825
  }
760
826
  async create(data) {
761
827
  if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
762
- this.store.set(data.username, structuredClone(data));
828
+ const cloned = structuredClone(data);
829
+ cloned.version = 0;
830
+ this.store.set(data.username, cloned);
763
831
  }
764
832
  async update(username, update) {
765
833
  const user = this.store.get(username);
766
834
  if (!user) return false;
835
+ if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
767
836
  if (update.set) require_user_store.deepMerge(user, update.set);
768
837
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) require_user_store.incrementAtPath(user, path, amount);
838
+ user.version = (user.version ?? 0) + 1;
769
839
  return true;
770
840
  }
771
841
  async delete(username) {
772
842
  return this.store.delete(username);
773
843
  }
844
+ async withCas(username, mutator, opts) {
845
+ const maxAttempts = opts?.maxAttempts ?? 2;
846
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
847
+ const current = await this.findByUsername(username);
848
+ if (!current) throw new require_user_store.UserAuthError("NOT_FOUND");
849
+ const patch = mutator(current);
850
+ if (patch === null) return;
851
+ if (await this.update(username, {
852
+ ...patch,
853
+ expectedVersion: current.version ?? 0
854
+ })) return;
855
+ }
856
+ throw new require_user_store.UserAuthError("CAS_EXHAUSTED");
857
+ }
774
858
  };
775
859
  //#endregion
776
860
  //#region src/password/policies.ts
777
- const ppHasMinLength = (min = 8) => ({
778
- rule: `v.length >= ${min}`,
861
+ const ppHasMinLength = (min = 8) => definePasswordPolicy({
862
+ rule: (v, min) => v.length >= min,
863
+ args: [min],
779
864
  description: `Minimum length ${min}`,
780
865
  errorMessage: `Password must be at least ${min} characters long`
781
866
  });
782
- const ppHasUpperCase = (n = 1) => ({
783
- rule: `(v.match(/[A-Z]/g) || []).length >= ${n}`,
867
+ const ppHasUpperCase = (n = 1) => definePasswordPolicy({
868
+ rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
869
+ args: [n],
784
870
  description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
785
871
  errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
786
872
  });
787
- const ppHasLowerCase = (n = 1) => ({
788
- rule: `(v.match(/[a-z]/g) || []).length >= ${n}`,
873
+ const ppHasLowerCase = (n = 1) => definePasswordPolicy({
874
+ rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
875
+ args: [n],
789
876
  description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
790
877
  errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
791
878
  });
792
- const ppHasNumber = (n = 1) => ({
793
- rule: `(v.match(/\\d/g) || []).length >= ${n}`,
879
+ const ppHasNumber = (n = 1) => definePasswordPolicy({
880
+ rule: (v, n) => (v.match(/\d/g) || []).length >= n,
881
+ args: [n],
794
882
  description: `At least ${n} number${n === 1 ? "" : "s"}`,
795
883
  errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
796
884
  });
797
- const ppHasSpecialChar = (n = 1) => ({
798
- rule: `(v.match(/[^A-Za-z0-9]/g) || []).length >= ${n}`,
885
+ const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
886
+ rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
887
+ args: [n],
799
888
  description: `At least ${n} special character${n === 1 ? "" : "s"}`,
800
889
  errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
801
890
  });
802
- const ppMaxRepeatedChars = (maxRepeated = 2) => ({
803
- rule: `/(.)\\1{${maxRepeated},}/.test(v) === false`,
891
+ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
892
+ rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
893
+ args: [maxRepeated],
804
894
  description: `No more than ${maxRepeated} consecutive repeated characters`,
805
895
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
806
896
  });
@@ -811,6 +901,7 @@ exports.UserAuthError = require_user_store.UserAuthError;
811
901
  exports.UserService = UserService;
812
902
  exports.UserStore = require_user_store.UserStore;
813
903
  exports.UserStoreMemory = UserStoreMemory;
904
+ exports.definePasswordPolicy = definePasswordPolicy;
814
905
  exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
815
906
  exports.generateMfaCode = generateMfaCode;
816
907
  exports.generateTotpCode = generateTotpCode;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as UserStoreUpdate, S as UserServiceConfig, _ as TotpConfig, a as LockoutConfig, b as UserAuthErrorType, c as MfaMethod, d as PasswordData, f as PasswordPolicyContext, g as PolicyCheckResult, h as PasswordPolicyInstance, i as LockStatus, l as MfaMethodInfo, m as PasswordPolicyEvalFn, n as AccountData, o as LoginResult, p as PasswordPolicyDef, r as DeepPartial, s as MfaData, t as UserStore, u as PasswordConfig, v as TransferablePolicy, x as UserCredentials, y as TrustedDeviceRecord } from "./user-store-VJPWNgdp.cjs";
1
+ import { C as UserServiceConfig, S as UserCredentials, _ as PolicyCheckResult, a as LockStatus, b as TrustedDeviceRecord, c as MfaData, d as PasswordConfig, f as PasswordData, g as PasswordPolicyInstance, h as PasswordPolicyEvalFn, i as DeepPartial, l as MfaMethod, m as PasswordPolicyDef, n as WithCasOptions, o as LockoutConfig, p as PasswordPolicyContext, r as AccountData, s as LoginResult, t as UserStore, u as MfaMethodInfo, v as TotpConfig, w as UserStoreUpdate, x as UserAuthErrorType, y as TransferablePolicy } from "./user-store-B3EStUfT.cjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -23,12 +23,39 @@ declare class PasswordHasher {
23
23
  //#endregion
24
24
  //#region src/password/policy.d.ts
25
25
  declare function normalizePolicies(policies?: (PasswordPolicyDef | PasswordPolicyInstance)[]): PasswordPolicy[];
26
+ /**
27
+ * Helper that builds a `PasswordPolicyDef` from a real backend function plus
28
+ * its bound positional arguments. The function runs directly on the server
29
+ * (no sandbox, no eval); `serialized` is auto-derived as
30
+ * `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
31
+ * so the same constraint can ship to the frontend without re-implementing it.
32
+ *
33
+ * Bundler-safe: positional invocation means renamed parameters inside the
34
+ * function body stay consistent — the call site only relies on argument
35
+ * ORDER, not identifier preservation.
36
+ *
37
+ * Constraint on the rule body: it MUST reference only its own parameters.
38
+ * No closures over module imports, no `this`, no helpers from outer scope —
39
+ * `rule.toString()` ships the literal source; any free identifier the
40
+ * frontend cannot resolve breaks transferability. (`String.prototype.match`,
41
+ * regex literals, plain JS globals are fine.)
42
+ *
43
+ * Omit `args` to mark the policy backend-only — `serialized` stays
44
+ * `undefined` and `transferable` is false; frontend pre-validation skips it
45
+ * and only the server check enforces the rule.
46
+ */
47
+ declare function definePasswordPolicy<A extends readonly unknown[]>(opts: {
48
+ rule: (v: string, ...args: A) => boolean | Promise<boolean>;
49
+ args?: A;
50
+ description?: string;
51
+ errorMessage?: string;
52
+ }): PasswordPolicyDef;
26
53
  declare class PasswordPolicy implements PasswordPolicyInstance {
27
- readonly rule: string | PasswordPolicyEvalFn;
54
+ readonly rule: PasswordPolicyEvalFn;
55
+ readonly serialized?: string;
28
56
  readonly description: string;
29
57
  readonly errorMessage: string;
30
58
  constructor(config: PasswordPolicyDef);
31
- protected _evalFn: PasswordPolicyEvalFn;
32
59
  evaluate(password: string, context?: PasswordPolicyContext): boolean | Promise<boolean>;
33
60
  get transferable(): boolean;
34
61
  }
@@ -75,7 +102,7 @@ declare class UserService<T extends object = object> {
75
102
  /**
76
103
  * Hard-delete the user row. Returns nothing on success. Throws
77
104
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
78
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
105
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
79
106
  */
80
107
  deleteUser(username: string): Promise<void>;
81
108
  /**
@@ -110,16 +137,13 @@ declare class UserService<T extends object = object> {
110
137
  /**
111
138
  * Consume a backup code: returns `true` and removes the matching hash
112
139
  * from storage if `code` matches a stored backup code; returns `false`
113
- * if no match (without modifying storage).
114
- *
115
- * Read-then-write is not atomic at this layer: two concurrent consumes
116
- * of the same code may both succeed, with the last write winning. The
117
- * underlying store API does not expose an atomic match-and-remove, so
118
- * this is acceptable at the intended scale (backup codes are a fallback
119
- * path, not a hot one). Wrap in your store's transaction primitive if a
120
- * stricter guarantee is required.
140
+ * if no match (without modifying storage). Single-use is enforced by
141
+ * optimistic-concurrency CAS on the version column — concurrent consumes
142
+ * of the same code race fairly and only one wins; the loser re-reads,
143
+ * finds the hash already removed, and returns `false`.
121
144
  *
122
145
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
146
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
123
147
  */
124
148
  consumeBackupCode(username: string, code: string): Promise<boolean>;
125
149
  /**
@@ -129,6 +153,18 @@ declare class UserService<T extends object = object> {
129
153
  * total tries across BOTH factors, not `2 * threshold`.
130
154
  */
131
155
  verifyMfa(username: string, code: string, config?: TotpConfig): Promise<void>;
156
+ /**
157
+ * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
158
+ * enrollment. Differs from `verifyMfa`:
159
+ * - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
160
+ * - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
161
+ * pre-activation; lockout doesn't apply).
162
+ * - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
163
+ *
164
+ * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
165
+ * totp method; MFA_INVALID on wrong code.
166
+ */
167
+ verifyTotpSetupCode(username: string, code: string, config?: TotpConfig): Promise<void>;
132
168
  getPasswordHasher(): PasswordHasher;
133
169
  getConfig(): Readonly<ResolvedConfig>;
134
170
  /**
@@ -184,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
184
220
  create(data: UserCredentials & T): Promise<void>;
185
221
  update(username: string, update: UserStoreUpdate): Promise<boolean>;
186
222
  delete(username: string): Promise<boolean>;
223
+ withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
187
224
  }
188
225
  //#endregion
189
226
  //#region src/password/policies.d.ts
@@ -198,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
198
235
  declare function generateTotpSecret(bytes?: number): string;
199
236
  declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
200
237
  declare function generateTotpCode(secret: string, config?: TotpConfig): string;
201
- declare function verifyTotpCode(secret: string, code: string, config?: TotpConfig): boolean;
238
+ /**
239
+ * Returns the matched HOTP counter when `code` is valid within the verification
240
+ * window, otherwise `null`. Returning the counter (not a bool) lets the caller
241
+ * persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
242
+ */
243
+ declare function verifyTotpCode(secret: string, code: string, config?: TotpConfig): number | null;
202
244
  declare function generateMfaCode(length?: number): string;
203
245
  //#endregion
204
246
  //#region src/mfa/codes.d.ts
@@ -238,4 +280,4 @@ declare function maskPhone(phone: string): string;
238
280
  declare function maskMfaValue(method: MfaMethod): string;
239
281
  declare function setAtPath(obj: object, path: string, value: unknown): void;
240
282
  //#endregion
241
- export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
283
+ export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };