@aooth/user 0.1.2 → 0.1.4

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
@@ -339,6 +368,14 @@ var UserService = class {
339
368
  this.hasher = new PasswordHasher(this.config.password);
340
369
  }
341
370
  /**
371
+ * Creates a user with `account.active: false`. The invite workflow relies
372
+ * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
373
+ * inactive until they accept). For setup scripts / seeders / tests that
374
+ * don't go through invite, follow up with `activateAccount(username)` or
375
+ * `login()` will throw `UserAuthError("INACTIVE")` — which the login
376
+ * workflow deliberately re-maps to `"Invalid credentials"` to avoid account
377
+ * enumeration, so the failure is silent client-side.
378
+ *
342
379
  * @param extras Optional partial user fields merged AFTER the base
343
380
  * `UserCredentials` shape, so callers can populate consumer-specific
344
381
  * required fields (e.g. `tenantId`) without subclassing the store.
@@ -418,7 +455,7 @@ var UserService = class {
418
455
  /**
419
456
  * Hard-delete the user row. Returns nothing on success. Throws
420
457
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
421
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
458
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
422
459
  */
423
460
  async deleteUser(username) {
424
461
  if (!await this.store.delete(username)) throw new require_user_store.UserAuthError("NOT_FOUND");
@@ -491,30 +528,32 @@ var UserService = class {
491
528
  }
492
529
  getTransferablePolicies() {
493
530
  return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
494
- rule: p.rule,
531
+ rule: p.serialized,
495
532
  description: p.description,
496
533
  errorMessage: p.errorMessage
497
534
  }));
498
535
  }
499
536
  async addMfaMethod(username, method) {
500
- const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
501
- 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
+ });
502
540
  }
503
541
  async confirmMfaMethod(username, name) {
504
- const user = await this.getUser(username);
505
- let found = false;
506
- const methods = user.mfa.methods.map((m) => {
507
- if (m.name === name) {
508
- found = true;
509
- return {
510
- ...m,
511
- confirmed: true
512
- };
513
- }
514
- 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 } } };
515
556
  });
516
- if (!found) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
517
- await this.store.update(username, { set: { mfa: { methods } } });
518
557
  }
519
558
  async removeMfaMethod(username, name) {
520
559
  const user = await this.getUser(username);
@@ -557,24 +596,27 @@ var UserService = class {
557
596
  /**
558
597
  * Consume a backup code: returns `true` and removes the matching hash
559
598
  * from storage if `code` matches a stored backup code; returns `false`
560
- * if no match (without modifying storage).
561
- *
562
- * Read-then-write is not atomic at this layer: two concurrent consumes
563
- * of the same code may both succeed, with the last write winning. The
564
- * underlying store API does not expose an atomic match-and-remove, so
565
- * this is acceptable at the intended scale (backup codes are a fallback
566
- * path, not a hot one). Wrap in your store's transaction primitive if a
567
- * 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`.
568
603
  *
569
604
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
605
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
570
606
  */
571
607
  async consumeBackupCode(username, code) {
572
- const hashes = (await this.getUser(username)).backupCodes ?? [];
573
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
574
- if (idx < 0) return false;
575
- const remaining = hashes.filter((_, i) => i !== idx);
576
- await this.store.update(username, { set: { backupCodes: remaining } });
577
- 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;
578
620
  }
579
621
  /**
580
622
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
@@ -588,12 +630,43 @@ var UserService = class {
588
630
  await this.ensureNotLockedOrThrow(username, user.account);
589
631
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
590
632
  if (!totp) throw new require_user_store.UserAuthError("MFA_NOT_CONFIGURED");
591
- if (verifyTotpCode(totp.value, code, config)) {
592
- if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
593
- 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;
594
651
  }
595
652
  await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
596
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
+ }
597
670
  getPasswordHasher() {
598
671
  return this.hasher;
599
672
  }
@@ -623,8 +696,9 @@ var UserService = class {
623
696
  * merge strategy replace the whole array.
624
697
  */
625
698
  async addTrustedDevice(username, record) {
626
- const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
627
- await this.store.update(username, { set: { trustedDevices: next } });
699
+ await this.store.withCas(username, (user) => {
700
+ return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
701
+ });
628
702
  }
629
703
  /**
630
704
  * Returns true when the supplied token (a) signs against the user+ip with
@@ -751,48 +825,72 @@ var UserStoreMemory = class extends require_user_store.UserStore {
751
825
  }
752
826
  async create(data) {
753
827
  if (this.store.has(data.username)) throw new require_user_store.UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
754
- this.store.set(data.username, structuredClone(data));
828
+ const cloned = structuredClone(data);
829
+ cloned.version = 0;
830
+ this.store.set(data.username, cloned);
755
831
  }
756
832
  async update(username, update) {
757
833
  const user = this.store.get(username);
758
834
  if (!user) return false;
835
+ if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
759
836
  if (update.set) require_user_store.deepMerge(user, update.set);
760
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;
761
839
  return true;
762
840
  }
763
841
  async delete(username) {
764
842
  return this.store.delete(username);
765
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
+ }
766
858
  };
767
859
  //#endregion
768
860
  //#region src/password/policies.ts
769
- const ppHasMinLength = (min = 8) => ({
770
- rule: `v.length >= ${min}`,
861
+ const ppHasMinLength = (min = 8) => definePasswordPolicy({
862
+ rule: (v, min) => v.length >= min,
863
+ args: [min],
771
864
  description: `Minimum length ${min}`,
772
865
  errorMessage: `Password must be at least ${min} characters long`
773
866
  });
774
- const ppHasUpperCase = (n = 1) => ({
775
- 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],
776
870
  description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
777
871
  errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
778
872
  });
779
- const ppHasLowerCase = (n = 1) => ({
780
- 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],
781
876
  description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
782
877
  errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
783
878
  });
784
- const ppHasNumber = (n = 1) => ({
785
- 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],
786
882
  description: `At least ${n} number${n === 1 ? "" : "s"}`,
787
883
  errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
788
884
  });
789
- const ppHasSpecialChar = (n = 1) => ({
790
- 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],
791
888
  description: `At least ${n} special character${n === 1 ? "" : "s"}`,
792
889
  errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
793
890
  });
794
- const ppMaxRepeatedChars = (maxRepeated = 2) => ({
795
- 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],
796
894
  description: `No more than ${maxRepeated} consecutive repeated characters`,
797
895
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
798
896
  });
@@ -803,6 +901,7 @@ exports.UserAuthError = require_user_store.UserAuthError;
803
901
  exports.UserService = UserService;
804
902
  exports.UserStore = require_user_store.UserStore;
805
903
  exports.UserStoreMemory = UserStoreMemory;
904
+ exports.definePasswordPolicy = definePasswordPolicy;
806
905
  exports.generateBackupCodePlaintext = generateBackupCodePlaintext;
807
906
  exports.generateMfaCode = generateMfaCode;
808
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
  }
@@ -50,6 +77,14 @@ declare class UserService<T extends object = object> {
50
77
  protected readonly hasher: PasswordHasher;
51
78
  constructor(store: UserStore<T>, config?: UserServiceConfig);
52
79
  /**
80
+ * Creates a user with `account.active: false`. The invite workflow relies
81
+ * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
82
+ * inactive until they accept). For setup scripts / seeders / tests that
83
+ * don't go through invite, follow up with `activateAccount(username)` or
84
+ * `login()` will throw `UserAuthError("INACTIVE")` — which the login
85
+ * workflow deliberately re-maps to `"Invalid credentials"` to avoid account
86
+ * enumeration, so the failure is silent client-side.
87
+ *
53
88
  * @param extras Optional partial user fields merged AFTER the base
54
89
  * `UserCredentials` shape, so callers can populate consumer-specific
55
90
  * required fields (e.g. `tenantId`) without subclassing the store.
@@ -67,7 +102,7 @@ declare class UserService<T extends object = object> {
67
102
  /**
68
103
  * Hard-delete the user row. Returns nothing on success. Throws
69
104
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
70
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
105
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
71
106
  */
72
107
  deleteUser(username: string): Promise<void>;
73
108
  /**
@@ -102,16 +137,13 @@ declare class UserService<T extends object = object> {
102
137
  /**
103
138
  * Consume a backup code: returns `true` and removes the matching hash
104
139
  * from storage if `code` matches a stored backup code; returns `false`
105
- * if no match (without modifying storage).
106
- *
107
- * Read-then-write is not atomic at this layer: two concurrent consumes
108
- * of the same code may both succeed, with the last write winning. The
109
- * underlying store API does not expose an atomic match-and-remove, so
110
- * this is acceptable at the intended scale (backup codes are a fallback
111
- * path, not a hot one). Wrap in your store's transaction primitive if a
112
- * 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`.
113
144
  *
114
145
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
146
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
115
147
  */
116
148
  consumeBackupCode(username: string, code: string): Promise<boolean>;
117
149
  /**
@@ -121,6 +153,18 @@ declare class UserService<T extends object = object> {
121
153
  * total tries across BOTH factors, not `2 * threshold`.
122
154
  */
123
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>;
124
168
  getPasswordHasher(): PasswordHasher;
125
169
  getConfig(): Readonly<ResolvedConfig>;
126
170
  /**
@@ -176,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
176
220
  create(data: UserCredentials & T): Promise<void>;
177
221
  update(username: string, update: UserStoreUpdate): Promise<boolean>;
178
222
  delete(username: string): Promise<boolean>;
223
+ withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
179
224
  }
180
225
  //#endregion
181
226
  //#region src/password/policies.d.ts
@@ -190,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
190
235
  declare function generateTotpSecret(bytes?: number): string;
191
236
  declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
192
237
  declare function generateTotpCode(secret: string, config?: TotpConfig): string;
193
- 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;
194
244
  declare function generateMfaCode(length?: number): string;
195
245
  //#endregion
196
246
  //#region src/mfa/codes.d.ts
@@ -230,4 +280,4 @@ declare function maskPhone(phone: string): string;
230
280
  declare function maskMfaValue(method: MfaMethod): string;
231
281
  declare function setAtPath(obj: object, path: string, value: unknown): void;
232
282
  //#endregion
233
- 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 };