@aooth/user 0.1.3 → 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.
package/dist/index.d.mts 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-Dbc9unW3.mjs";
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-C1lxahSB.mjs";
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 };
package/dist/index.mjs CHANGED
@@ -1,6 +1,5 @@
1
- import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-B_l9vqlQ.mjs";
1
+ import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-BaBmH13V.mjs";
2
2
  import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
3
- import { FtringsPool } from "@prostojs/ftring";
4
3
  //#region src/mfa/backup-codes.ts
5
4
  /**
6
5
  * Custom alphabet for backup codes — uppercase letters and digits with the
@@ -145,6 +144,11 @@ function generateTotpCode(secret, config) {
145
144
  const counter = Math.floor(now / 1e3 / period);
146
145
  return hotpCode(decode(secret), counter, digits);
147
146
  }
147
+ /**
148
+ * Returns the matched HOTP counter when `code` is valid within the verification
149
+ * window, otherwise `null`. Returning the counter (not a bool) lets the caller
150
+ * persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
151
+ */
148
152
  function verifyTotpCode(secret, code, config) {
149
153
  const period = config?.period ?? 30;
150
154
  const digits = config?.digits ?? 6;
@@ -152,14 +156,15 @@ function verifyTotpCode(secret, code, config) {
152
156
  const now = (config?.clock ?? Date.now)();
153
157
  const counter = Math.floor(now / 1e3 / period);
154
158
  const key = decode(secret);
155
- if (typeof code !== "string" || code.length !== digits) return false;
159
+ if (typeof code !== "string" || code.length !== digits) return null;
156
160
  const submitted = Buffer.from(code, "utf8");
157
- let matched = false;
161
+ let matchedCounter = null;
158
162
  for (let i = -window; i <= window; i++) {
159
- const expected = Buffer.from(hotpCode(key, counter + i, digits), "utf8");
160
- if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matched = true;
163
+ const stepCounter = counter + i;
164
+ const expected = Buffer.from(hotpCode(key, stepCounter, digits), "utf8");
165
+ if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matchedCounter = stepCounter;
161
166
  }
162
- return matched;
167
+ return matchedCounter;
163
168
  }
164
169
  function generateMfaCode(length = 6) {
165
170
  return generateSecureRandom(length, "0123456789");
@@ -268,33 +273,57 @@ var PasswordHasher = class {
268
273
  };
269
274
  //#endregion
270
275
  //#region src/password/policy.ts
271
- const fnPool = new FtringsPool();
272
276
  function normalizePolicies(policies) {
273
277
  return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
274
278
  }
279
+ /**
280
+ * Helper that builds a `PasswordPolicyDef` from a real backend function plus
281
+ * its bound positional arguments. The function runs directly on the server
282
+ * (no sandbox, no eval); `serialized` is auto-derived as
283
+ * `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
284
+ * so the same constraint can ship to the frontend without re-implementing it.
285
+ *
286
+ * Bundler-safe: positional invocation means renamed parameters inside the
287
+ * function body stay consistent — the call site only relies on argument
288
+ * ORDER, not identifier preservation.
289
+ *
290
+ * Constraint on the rule body: it MUST reference only its own parameters.
291
+ * No closures over module imports, no `this`, no helpers from outer scope —
292
+ * `rule.toString()` ships the literal source; any free identifier the
293
+ * frontend cannot resolve breaks transferability. (`String.prototype.match`,
294
+ * regex literals, plain JS globals are fine.)
295
+ *
296
+ * Omit `args` to mark the policy backend-only — `serialized` stays
297
+ * `undefined` and `transferable` is false; frontend pre-validation skips it
298
+ * and only the server check enforces the rule.
299
+ */
300
+ function definePasswordPolicy(opts) {
301
+ const { rule, args, description, errorMessage } = opts;
302
+ const def = { rule: args ? (v) => rule(v, ...args) : (v) => rule(v, ...[]) };
303
+ if (args) {
304
+ const argList = args.map((a) => JSON.stringify(a)).join(", ");
305
+ def.serialized = argList.length > 0 ? `(v) => (${rule.toString()})(v, ${argList})` : `(v) => (${rule.toString()})(v)`;
306
+ }
307
+ if (description !== void 0) def.description = description;
308
+ if (errorMessage !== void 0) def.errorMessage = errorMessage;
309
+ return def;
310
+ }
275
311
  var PasswordPolicy = class {
276
312
  rule;
313
+ serialized;
277
314
  description;
278
315
  errorMessage;
279
316
  constructor(config) {
280
317
  this.rule = config.rule;
318
+ if (config.serialized !== void 0) this.serialized = config.serialized;
281
319
  this.description = config.description || "";
282
320
  this.errorMessage = config.errorMessage || "";
283
321
  }
284
- _evalFn;
285
322
  evaluate(password, context) {
286
- if (!this._evalFn) if (typeof this.rule === "function") this._evalFn = this.rule;
287
- else if (typeof this.rule === "string" && this.rule) {
288
- const fn = fnPool.getFn(this.rule);
289
- this._evalFn = (v, ctx) => fn({
290
- v,
291
- context: ctx
292
- });
293
- } else this._evalFn = () => true;
294
- return this._evalFn(password, context);
323
+ return this.rule(password, context);
295
324
  }
296
325
  get transferable() {
297
- return typeof this.rule === "string";
326
+ return this.serialized !== void 0;
298
327
  }
299
328
  };
300
329
  //#endregion
@@ -425,7 +454,7 @@ var UserService = class {
425
454
  /**
426
455
  * Hard-delete the user row. Returns nothing on success. Throws
427
456
  * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
428
- * invite workflow's `auth.cancelInvite` to revoke a pending invitation.
457
+ * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
429
458
  */
430
459
  async deleteUser(username) {
431
460
  if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
@@ -498,30 +527,32 @@ var UserService = class {
498
527
  }
499
528
  getTransferablePolicies() {
500
529
  return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
501
- rule: p.rule,
530
+ rule: p.serialized,
502
531
  description: p.description,
503
532
  errorMessage: p.errorMessage
504
533
  }));
505
534
  }
506
535
  async addMfaMethod(username, method) {
507
- const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
508
- await this.store.update(username, { set: { mfa: { methods } } });
536
+ await this.store.withCas(username, (user) => {
537
+ return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
538
+ });
509
539
  }
510
540
  async confirmMfaMethod(username, name) {
511
- const user = await this.getUser(username);
512
- let found = false;
513
- const methods = user.mfa.methods.map((m) => {
514
- if (m.name === name) {
515
- found = true;
516
- return {
517
- ...m,
518
- confirmed: true
519
- };
520
- }
521
- return m;
541
+ await this.store.withCas(username, (user) => {
542
+ let found = false;
543
+ const methods = user.mfa.methods.map((m) => {
544
+ if (m.name === name) {
545
+ found = true;
546
+ return {
547
+ ...m,
548
+ confirmed: true
549
+ };
550
+ }
551
+ return m;
552
+ });
553
+ if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
554
+ return { set: { mfa: { methods } } };
522
555
  });
523
- if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
524
- await this.store.update(username, { set: { mfa: { methods } } });
525
556
  }
526
557
  async removeMfaMethod(username, name) {
527
558
  const user = await this.getUser(username);
@@ -564,24 +595,27 @@ var UserService = class {
564
595
  /**
565
596
  * Consume a backup code: returns `true` and removes the matching hash
566
597
  * from storage if `code` matches a stored backup code; returns `false`
567
- * if no match (without modifying storage).
568
- *
569
- * Read-then-write is not atomic at this layer: two concurrent consumes
570
- * of the same code may both succeed, with the last write winning. The
571
- * underlying store API does not expose an atomic match-and-remove, so
572
- * this is acceptable at the intended scale (backup codes are a fallback
573
- * path, not a hot one). Wrap in your store's transaction primitive if a
574
- * stricter guarantee is required.
598
+ * if no match (without modifying storage). Single-use is enforced by
599
+ * optimistic-concurrency CAS on the version column — concurrent consumes
600
+ * of the same code race fairly and only one wins; the loser re-reads,
601
+ * finds the hash already removed, and returns `false`.
575
602
  *
576
603
  * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
604
+ * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
577
605
  */
578
606
  async consumeBackupCode(username, code) {
579
- const hashes = (await this.getUser(username)).backupCodes ?? [];
580
- const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
581
- if (idx < 0) return false;
582
- const remaining = hashes.filter((_, i) => i !== idx);
583
- await this.store.update(username, { set: { backupCodes: remaining } });
584
- return true;
607
+ let consumed = false;
608
+ await this.store.withCas(username, (user) => {
609
+ const hashes = user.backupCodes ?? [];
610
+ const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
611
+ if (idx < 0) {
612
+ consumed = false;
613
+ return null;
614
+ }
615
+ consumed = true;
616
+ return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
617
+ });
618
+ return consumed;
585
619
  }
586
620
  /**
587
621
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
@@ -595,12 +629,43 @@ var UserService = class {
595
629
  await this.ensureNotLockedOrThrow(username, user.account);
596
630
  const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
597
631
  if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
598
- if (verifyTotpCode(totp.value, code, config)) {
599
- if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
600
- return;
632
+ const matchedCounter = verifyTotpCode(totp.value, code, config);
633
+ const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
634
+ if (matchedCounter !== null && !isReplay) {
635
+ let replayDuringCas = false;
636
+ await this.store.withCas(username, (current) => {
637
+ const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
638
+ if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
639
+ replayDuringCas = true;
640
+ return null;
641
+ }
642
+ const set = { mfa: { methods: current.mfa.methods.map((m) => m.name === "totp" && m.confirmed ? {
643
+ ...m,
644
+ lastUsedWindow: matchedCounter
645
+ } : m) } };
646
+ if (current.account.failedLoginAttempts > 0) set.account = { failedLoginAttempts: 0 };
647
+ return { set };
648
+ });
649
+ if (!replayDuringCas) return;
601
650
  }
602
651
  await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
603
652
  }
653
+ /**
654
+ * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
655
+ * enrollment. Differs from `verifyMfa`:
656
+ * - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
657
+ * - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
658
+ * pre-activation; lockout doesn't apply).
659
+ * - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
660
+ *
661
+ * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
662
+ * totp method; MFA_INVALID on wrong code.
663
+ */
664
+ async verifyTotpSetupCode(username, code, config) {
665
+ const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
666
+ if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
667
+ if (verifyTotpCode(totp.value, code, config) === null) throw new UserAuthError("MFA_INVALID");
668
+ }
604
669
  getPasswordHasher() {
605
670
  return this.hasher;
606
671
  }
@@ -630,8 +695,9 @@ var UserService = class {
630
695
  * merge strategy replace the whole array.
631
696
  */
632
697
  async addTrustedDevice(username, record) {
633
- const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
634
- await this.store.update(username, { set: { trustedDevices: next } });
698
+ await this.store.withCas(username, (user) => {
699
+ return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
700
+ });
635
701
  }
636
702
  /**
637
703
  * Returns true when the supplied token (a) signs against the user+ip with
@@ -758,50 +824,74 @@ var UserStoreMemory = class extends UserStore {
758
824
  }
759
825
  async create(data) {
760
826
  if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
761
- this.store.set(data.username, structuredClone(data));
827
+ const cloned = structuredClone(data);
828
+ cloned.version = 0;
829
+ this.store.set(data.username, cloned);
762
830
  }
763
831
  async update(username, update) {
764
832
  const user = this.store.get(username);
765
833
  if (!user) return false;
834
+ if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
766
835
  if (update.set) deepMerge(user, update.set);
767
836
  if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
837
+ user.version = (user.version ?? 0) + 1;
768
838
  return true;
769
839
  }
770
840
  async delete(username) {
771
841
  return this.store.delete(username);
772
842
  }
843
+ async withCas(username, mutator, opts) {
844
+ const maxAttempts = opts?.maxAttempts ?? 2;
845
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
846
+ const current = await this.findByUsername(username);
847
+ if (!current) throw new UserAuthError("NOT_FOUND");
848
+ const patch = mutator(current);
849
+ if (patch === null) return;
850
+ if (await this.update(username, {
851
+ ...patch,
852
+ expectedVersion: current.version ?? 0
853
+ })) return;
854
+ }
855
+ throw new UserAuthError("CAS_EXHAUSTED");
856
+ }
773
857
  };
774
858
  //#endregion
775
859
  //#region src/password/policies.ts
776
- const ppHasMinLength = (min = 8) => ({
777
- rule: `v.length >= ${min}`,
860
+ const ppHasMinLength = (min = 8) => definePasswordPolicy({
861
+ rule: (v, min) => v.length >= min,
862
+ args: [min],
778
863
  description: `Minimum length ${min}`,
779
864
  errorMessage: `Password must be at least ${min} characters long`
780
865
  });
781
- const ppHasUpperCase = (n = 1) => ({
782
- rule: `(v.match(/[A-Z]/g) || []).length >= ${n}`,
866
+ const ppHasUpperCase = (n = 1) => definePasswordPolicy({
867
+ rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
868
+ args: [n],
783
869
  description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
784
870
  errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
785
871
  });
786
- const ppHasLowerCase = (n = 1) => ({
787
- rule: `(v.match(/[a-z]/g) || []).length >= ${n}`,
872
+ const ppHasLowerCase = (n = 1) => definePasswordPolicy({
873
+ rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
874
+ args: [n],
788
875
  description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
789
876
  errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
790
877
  });
791
- const ppHasNumber = (n = 1) => ({
792
- rule: `(v.match(/\\d/g) || []).length >= ${n}`,
878
+ const ppHasNumber = (n = 1) => definePasswordPolicy({
879
+ rule: (v, n) => (v.match(/\d/g) || []).length >= n,
880
+ args: [n],
793
881
  description: `At least ${n} number${n === 1 ? "" : "s"}`,
794
882
  errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
795
883
  });
796
- const ppHasSpecialChar = (n = 1) => ({
797
- rule: `(v.match(/[^A-Za-z0-9]/g) || []).length >= ${n}`,
884
+ const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
885
+ rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
886
+ args: [n],
798
887
  description: `At least ${n} special character${n === 1 ? "" : "s"}`,
799
888
  errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
800
889
  });
801
- const ppMaxRepeatedChars = (maxRepeated = 2) => ({
802
- rule: `/(.)\\1{${maxRepeated},}/.test(v) === false`,
890
+ const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
891
+ rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
892
+ args: [maxRepeated],
803
893
  description: `No more than ${maxRepeated} consecutive repeated characters`,
804
894
  errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
805
895
  });
806
896
  //#endregion
807
- export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
897
+ export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
@@ -2,6 +2,14 @@
2
2
  interface UserCredentials {
3
3
  id: string;
4
4
  username: string;
5
+ /**
6
+ * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
7
+ * on every successful write; checked against `UserStoreUpdate.expectedVersion`
8
+ * for CAS. Callers MUST NOT write it directly — atscript-db rejects direct
9
+ * writes with `DbError("VERSION_COLUMN_WRITE")`. Optional in TS so pre-OCC
10
+ * fixtures keep compiling; the store seeds `0` on insert.
11
+ */
12
+ version?: number;
5
13
  password: PasswordData;
6
14
  account: AccountData;
7
15
  mfa: MfaData;
@@ -49,8 +57,8 @@ interface AccountData {
49
57
  * True while the user record exists from an admin-issued invite but the
50
58
  * invitee has not yet accepted (set password + activate). Used by
51
59
  * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
52
- * power `auth.reInvite` / `auth.cancelInvite`. Absent / `false` once the
53
- * invite has been accepted.
60
+ * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
61
+ * the invite has been accepted.
54
62
  */
55
63
  pendingInvitation?: boolean;
56
64
  }
@@ -69,6 +77,12 @@ interface MfaMethod {
69
77
  confirmed: boolean;
70
78
  /** The method's value: email address, phone number, or TOTP secret */
71
79
  value: string;
80
+ /**
81
+ * Last HOTP counter accepted for this method (TOTP only). Server-managed
82
+ * replay guard — `verifyMfa` rejects any code whose matched counter is
83
+ * `<= lastUsedWindow`. Never written from user-facing input.
84
+ */
85
+ lastUsedWindow?: number;
72
86
  }
73
87
  interface UserServiceConfig {
74
88
  password?: PasswordConfig;
@@ -107,7 +121,23 @@ interface LockoutConfig {
107
121
  duration?: number;
108
122
  }
109
123
  interface PasswordPolicyDef {
110
- rule: string | PasswordPolicyEvalFn;
124
+ /**
125
+ * Backend evaluator. Function only — executed directly with no sandbox.
126
+ * String rules were removed: `@prostojs/ftring`'s sandbox does NOT block
127
+ * prototype-chain escapes (`constructor.constructor("return process")()`,
128
+ * `__proto__.x = ...`), so accepting strings was an RCE vector. Authors
129
+ * use `definePasswordPolicy({ rule, args })` to get both this fn AND the
130
+ * serialized form for free.
131
+ */
132
+ rule: PasswordPolicyEvalFn;
133
+ /**
134
+ * Pre-baked function-literal text shipped to clients for cross-tier
135
+ * validation via `getTransferablePolicies()`. Authored as
136
+ * `(v) => (${ruleSource})(v, ${args.map(JSON.stringify).join(', ')})` by
137
+ * `definePasswordPolicy`. Absent → the policy is backend-only (frontend
138
+ * skips it; server-side check remains authoritative).
139
+ */
140
+ serialized?: string;
111
141
  description?: string;
112
142
  errorMessage?: string;
113
143
  }
@@ -127,8 +157,15 @@ interface UserStoreUpdate {
127
157
  set?: DeepPartial<UserCredentials>;
128
158
  /** Dot-paths to atomically increment: e.g. {'account.failedLoginAttempts': 1} */
129
159
  inc?: Record<string, number>;
160
+ /**
161
+ * Optimistic concurrency control: when supplied, the store applies the
162
+ * update iff the row's current `version` equals this value. On mismatch
163
+ * the store returns `false` (same shape as "not found") and does NOT
164
+ * mutate. Service callers treat both states as "stale read, retry".
165
+ */
166
+ expectedVersion?: number;
130
167
  }
131
- type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED";
168
+ type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED" | "CAS_EXHAUSTED";
132
169
  interface LoginResult<T extends object = object> {
133
170
  user: UserCredentials & T;
134
171
  /** Whether MFA verification is required before granting full access */
@@ -171,6 +208,15 @@ interface TotpConfig {
171
208
  }
172
209
  //#endregion
173
210
  //#region src/store/user-store.d.ts
211
+ interface WithCasOptions {
212
+ /**
213
+ * Total attempts (1 initial + retries). Default `2` = one retry. Each
214
+ * attempt re-reads the row so the mutator runs against fresh state — that
215
+ * is the whole point of retry under OCC. Bump for high-contention writers
216
+ * (bulk admin scripts); leave at default for normal per-user request flow.
217
+ */
218
+ maxAttempts?: number;
219
+ }
174
220
  declare abstract class UserStore<T extends object = object> {
175
221
  abstract exists(username: string): Promise<boolean>;
176
222
  abstract findByUsername(username: string): Promise<(UserCredentials & T) | null>;
@@ -179,9 +225,22 @@ declare abstract class UserStore<T extends object = object> {
179
225
  /**
180
226
  * Hard-delete the row. Returns `true` when a row was removed, `false` when
181
227
  * the username was not found. Used by `UserService.deleteUser` (and in turn
182
- * by the invite workflow's `auth.cancelInvite` step).
228
+ * by the invite workflow's `auth/invite/cancel` step).
183
229
  */
184
230
  abstract delete(username: string): Promise<boolean>;
231
+ /**
232
+ * Run a read-modify-write cycle under optimistic concurrency. Each attempt
233
+ * fetches the current row, calls `mutator` with it, and applies the returned
234
+ * patch under CAS (`expectedVersion = current.version`). On CAS miss the
235
+ * cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null` to
236
+ * exit early without writing — used for "race-loser detects nothing left to
237
+ * do" paths (e.g. the backup code was already consumed by the winner).
238
+ *
239
+ * Throws `UserAuthError("NOT_FOUND")` when no row matches `username`, or
240
+ * `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
241
+ * thrown from inside `mutator` propagate immediately without retry.
242
+ */
243
+ abstract withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
185
244
  }
186
245
  //#endregion
187
- export { UserStoreUpdate as C, UserServiceConfig as S, TotpConfig as _, LockoutConfig as a, UserAuthErrorType as b, MfaMethod as c, PasswordData as d, PasswordPolicyContext as f, PolicyCheckResult as g, PasswordPolicyInstance as h, LockStatus as i, MfaMethodInfo as l, PasswordPolicyEvalFn as m, AccountData as n, LoginResult as o, PasswordPolicyDef as p, DeepPartial as r, MfaData as s, UserStore as t, PasswordConfig as u, TransferablePolicy as v, UserCredentials as x, TrustedDeviceRecord as y };
246
+ export { UserServiceConfig as C, UserCredentials as S, PolicyCheckResult as _, LockStatus as a, TrustedDeviceRecord as b, MfaData as c, PasswordConfig as d, PasswordData as f, PasswordPolicyInstance as g, PasswordPolicyEvalFn as h, DeepPartial as i, MfaMethod as l, PasswordPolicyDef as m, WithCasOptions as n, LockoutConfig as o, PasswordPolicyContext as p, AccountData as r, LoginResult as s, UserStore as t, MfaMethodInfo as u, TotpConfig as v, UserStoreUpdate as w, UserAuthErrorType as x, TransferablePolicy as y };
@@ -11,7 +11,8 @@ const defaultMessages = {
11
11
  PASSWORD_IN_HISTORY: "Password was recently used",
12
12
  MFA_REQUIRED: "Multi-factor authentication is required",
13
13
  MFA_INVALID: "Invalid MFA code",
14
- MFA_NOT_CONFIGURED: "MFA method is not configured"
14
+ MFA_NOT_CONFIGURED: "MFA method is not configured",
15
+ CAS_EXHAUSTED: "Update conflict — please retry"
15
16
  };
16
17
  var UserAuthError = class extends Error {
17
18
  name = "UserAuthError";
@@ -11,7 +11,8 @@ const defaultMessages = {
11
11
  PASSWORD_IN_HISTORY: "Password was recently used",
12
12
  MFA_REQUIRED: "Multi-factor authentication is required",
13
13
  MFA_INVALID: "Invalid MFA code",
14
- MFA_NOT_CONFIGURED: "MFA method is not configured"
14
+ MFA_NOT_CONFIGURED: "MFA method is not configured",
15
+ CAS_EXHAUSTED: "Update conflict — please retry"
15
16
  };
16
17
  var UserAuthError = class extends Error {
17
18
  name = "UserAuthError";