@aooth/user 0.1.85 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-COXONHvx.cjs";
1
+ import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-B_jEkl-a.cjs";
2
2
  //#region src/atscript-db/federated-identity-store.d.ts
3
3
  /**
4
4
  * Structural surface of `AtscriptDbTable` covering exactly the methods this
@@ -1,4 +1,4 @@
1
- import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-COXONHvx.mjs";
1
+ import { D as UserCredentials, i as NewFederatedIdentity, k as UserStoreUpdate, n as FederatedIdentityStore, o as UserStore, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity } from "./federated-identity-store-B_jEkl-a.mjs";
2
2
  //#region src/atscript-db/federated-identity-store.d.ts
3
3
  /**
4
4
  * Structural surface of `AtscriptDbTable` covering exactly the methods this
@@ -65,6 +65,14 @@ interface AccountData {
65
65
  * the invite has been accepted.
66
66
  */
67
67
  pendingInvitation?: boolean;
68
+ /** Id of the user who issued the pending invite (written by the invite flow). */
69
+ invitedBy?: string;
70
+ /**
71
+ * Random marker of the current invite link. A re-invite rotates it, so a
72
+ * link issued before the re-invite answers 410 ("replaced") instead of
73
+ * activating the account. Absent on rows created before 0.2.
74
+ */
75
+ inviteNonce?: string;
68
76
  /**
69
77
  * Correspondence address whose inbox the user PROVED ownership of — written
70
78
  * at every inbox-proof moment (invite magic-link click, signup / recovery
@@ -65,6 +65,14 @@ interface AccountData {
65
65
  * the invite has been accepted.
66
66
  */
67
67
  pendingInvitation?: boolean;
68
+ /** Id of the user who issued the pending invite (written by the invite flow). */
69
+ invitedBy?: string;
70
+ /**
71
+ * Random marker of the current invite link. A re-invite rotates it, so a
72
+ * link issued before the re-invite answers 410 ("replaced") instead of
73
+ * activating the account. Absent on rows created before 0.2.
74
+ */
75
+ inviteNonce?: string;
68
76
  /**
69
77
  * Correspondence address whose inbox the user PROVED ownership of — written
70
78
  * at every inbox-proof moment (invite magic-link click, signup / recovery
package/dist/index.cjs CHANGED
@@ -487,9 +487,14 @@ var UserService = class {
487
487
  }
488
488
  async login(handle, password, lockoutOverride) {
489
489
  const user = await this.store.findByHandle(handle);
490
- if (!user) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
491
- if (!user.account.active) throw new require_federated_identity_store.UserAuthError("INACTIVE");
492
- await this.ensureNotLockedOrThrow(user.id, user.account);
490
+ try {
491
+ if (!user) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
492
+ if (!user.account.active) throw new require_federated_identity_store.UserAuthError("INACTIVE");
493
+ await this.ensureNotLockedOrThrow(user.id, user.account);
494
+ } catch (err) {
495
+ await this.burnPasswordHash(password);
496
+ throw err;
497
+ }
493
498
  if (await this.hasher.verify(password, user.password.hash)) {
494
499
  const now = await this.recordLogin(user.id);
495
500
  user.account.lastLogin = now;
@@ -556,6 +561,26 @@ var UserService = class {
556
561
  return this.getUser(id);
557
562
  }
558
563
  /**
564
+ * Accept a pending invitation in ONE compare-and-set write: clears
565
+ * `account.pendingInvitation` only while the row is still pending and its
566
+ * `account.inviteNonce` equals `nonce` (a row without a nonce — written
567
+ * before 0.2 — accepts any). Returns `false` (nothing written) when the
568
+ * invite was already accepted or replaced by a newer one, so two links of
569
+ * the same account can never both complete.
570
+ */
571
+ async acceptInvitation(id, nonce) {
572
+ let accepted = false;
573
+ await this.store.withCas(id, (user) => {
574
+ accepted = false;
575
+ const account = user.account;
576
+ if (!account.pendingInvitation) return null;
577
+ if (account.inviteNonce !== void 0 && account.inviteNonce !== nonce) return null;
578
+ accepted = true;
579
+ return { set: { account: { pendingInvitation: false } } };
580
+ });
581
+ return accepted;
582
+ }
583
+ /**
559
584
  * Flip the account active. `opts.verifiedEmail` lets callers that just
560
585
  * proved an inbox (invite magic link, signup OTP) record the correspondence
561
586
  * address in the same store write — see {@link setVerifiedEmail}.
@@ -978,6 +1003,21 @@ var UserService = class {
978
1003
  hasConfirmedMfaMethods(mfa) {
979
1004
  return mfa.methods.some((m) => m.confirmed);
980
1005
  }
1006
+ /** Lazily minted hash of a random password, with the configured hasher params. */
1007
+ dummyHash;
1008
+ /**
1009
+ * Verify `password` against a throwaway hash — same cost as a real check.
1010
+ * The early exits still skip the failed-attempt counter write a wrong
1011
+ * password costs (one store round trip); `AuthWorkflow`'s `'uniform'`
1012
+ * lockout disclosure spends a comparable shadow-counter write on them.
1013
+ */
1014
+ async burnPasswordHash(password) {
1015
+ this.dummyHash ??= this.hasher.hash(this.hasher.generatePassword()).catch((err) => {
1016
+ this.dummyHash = void 0;
1017
+ throw err;
1018
+ });
1019
+ await this.hasher.verify(password, await this.dummyHash);
1020
+ }
981
1021
  /**
982
1022
  * If `account.locked`: auto-unlock when the lock has expired (mutating
983
1023
  * `account` in place), or throw `LOCKED` otherwise.
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-COXONHvx.cjs";
1
+ import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-B_jEkl-a.cjs";
2
2
  //#region src/errors.d.ts
3
3
  declare class UserAuthError extends Error {
4
4
  readonly type: UserAuthErrorType;
@@ -178,6 +178,15 @@ declare class UserService<T extends object = object> {
178
178
  * Used by the invite workflow's `applyProfile` default fallback.
179
179
  */
180
180
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
181
+ /**
182
+ * Accept a pending invitation in ONE compare-and-set write: clears
183
+ * `account.pendingInvitation` only while the row is still pending and its
184
+ * `account.inviteNonce` equals `nonce` (a row without a nonce — written
185
+ * before 0.2 — accepts any). Returns `false` (nothing written) when the
186
+ * invite was already accepted or replaced by a newer one, so two links of
187
+ * the same account can never both complete.
188
+ */
189
+ acceptInvitation(id: string, nonce: string | undefined): Promise<boolean>;
181
190
  /**
182
191
  * Flip the account active. `opts.verifiedEmail` lets callers that just
183
192
  * proved an inbox (invite magic link, signup OTP) record the correspondence
@@ -350,6 +359,15 @@ declare class UserService<T extends object = object> {
350
359
  getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
351
360
  private applyPasswordChange;
352
361
  private hasConfirmedMfaMethods;
362
+ /** Lazily minted hash of a random password, with the configured hasher params. */
363
+ private dummyHash?;
364
+ /**
365
+ * Verify `password` against a throwaway hash — same cost as a real check.
366
+ * The early exits still skip the failed-attempt counter write a wrong
367
+ * password costs (one store round trip); `AuthWorkflow`'s `'uniform'`
368
+ * lockout disclosure spends a comparable shadow-counter write on them.
369
+ */
370
+ private burnPasswordHash;
353
371
  /**
354
372
  * If `account.locked`: auto-unlock when the lock has expired (mutating
355
373
  * `account` in place), or throw `LOCKED` otherwise.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-COXONHvx.mjs";
1
+ import { C as TotpConfig, D as UserCredentials, E as UserAuthErrorType, O as UserServiceConfig, S as PolicyCheckResult, T as TrustedDeviceRecord, _ as PasswordData, a as pickDefinedProfile, b as PasswordPolicyEvalFn, c as AccountData, d as LockoutConfig, f as LoginResult, g as PasswordConfig, h as MfaMethodInfo, i as NewFederatedIdentity, k as UserStoreUpdate, l as DeepPartial, m as MfaMethod, n as FederatedIdentityStore, o as UserStore, p as MfaData, r as FederatedProfileSnapshot, s as WithCasOptions, t as FederatedIdentity, u as LockStatus, v as PasswordPolicyContext, w as TransferablePolicy, x as PasswordPolicyInstance, y as PasswordPolicyDef } from "./federated-identity-store-B_jEkl-a.mjs";
2
2
  //#region src/errors.d.ts
3
3
  declare class UserAuthError extends Error {
4
4
  readonly type: UserAuthErrorType;
@@ -178,6 +178,15 @@ declare class UserService<T extends object = object> {
178
178
  * Used by the invite workflow's `applyProfile` default fallback.
179
179
  */
180
180
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
181
+ /**
182
+ * Accept a pending invitation in ONE compare-and-set write: clears
183
+ * `account.pendingInvitation` only while the row is still pending and its
184
+ * `account.inviteNonce` equals `nonce` (a row without a nonce — written
185
+ * before 0.2 — accepts any). Returns `false` (nothing written) when the
186
+ * invite was already accepted or replaced by a newer one, so two links of
187
+ * the same account can never both complete.
188
+ */
189
+ acceptInvitation(id: string, nonce: string | undefined): Promise<boolean>;
181
190
  /**
182
191
  * Flip the account active. `opts.verifiedEmail` lets callers that just
183
192
  * proved an inbox (invite magic link, signup OTP) record the correspondence
@@ -350,6 +359,15 @@ declare class UserService<T extends object = object> {
350
359
  getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
351
360
  private applyPasswordChange;
352
361
  private hasConfirmedMfaMethods;
362
+ /** Lazily minted hash of a random password, with the configured hasher params. */
363
+ private dummyHash?;
364
+ /**
365
+ * Verify `password` against a throwaway hash — same cost as a real check.
366
+ * The early exits still skip the failed-attempt counter write a wrong
367
+ * password costs (one store round trip); `AuthWorkflow`'s `'uniform'`
368
+ * lockout disclosure spends a comparable shadow-counter write on them.
369
+ */
370
+ private burnPasswordHash;
353
371
  /**
354
372
  * If `account.locked`: auto-unlock when the lock has expired (mutating
355
373
  * `account` in place), or throw `LOCKED` otherwise.
package/dist/index.mjs CHANGED
@@ -486,9 +486,14 @@ var UserService = class {
486
486
  }
487
487
  async login(handle, password, lockoutOverride) {
488
488
  const user = await this.store.findByHandle(handle);
489
- if (!user) throw new UserAuthError("NOT_FOUND");
490
- if (!user.account.active) throw new UserAuthError("INACTIVE");
491
- await this.ensureNotLockedOrThrow(user.id, user.account);
489
+ try {
490
+ if (!user) throw new UserAuthError("NOT_FOUND");
491
+ if (!user.account.active) throw new UserAuthError("INACTIVE");
492
+ await this.ensureNotLockedOrThrow(user.id, user.account);
493
+ } catch (err) {
494
+ await this.burnPasswordHash(password);
495
+ throw err;
496
+ }
492
497
  if (await this.hasher.verify(password, user.password.hash)) {
493
498
  const now = await this.recordLogin(user.id);
494
499
  user.account.lastLogin = now;
@@ -555,6 +560,26 @@ var UserService = class {
555
560
  return this.getUser(id);
556
561
  }
557
562
  /**
563
+ * Accept a pending invitation in ONE compare-and-set write: clears
564
+ * `account.pendingInvitation` only while the row is still pending and its
565
+ * `account.inviteNonce` equals `nonce` (a row without a nonce — written
566
+ * before 0.2 — accepts any). Returns `false` (nothing written) when the
567
+ * invite was already accepted or replaced by a newer one, so two links of
568
+ * the same account can never both complete.
569
+ */
570
+ async acceptInvitation(id, nonce) {
571
+ let accepted = false;
572
+ await this.store.withCas(id, (user) => {
573
+ accepted = false;
574
+ const account = user.account;
575
+ if (!account.pendingInvitation) return null;
576
+ if (account.inviteNonce !== void 0 && account.inviteNonce !== nonce) return null;
577
+ accepted = true;
578
+ return { set: { account: { pendingInvitation: false } } };
579
+ });
580
+ return accepted;
581
+ }
582
+ /**
558
583
  * Flip the account active. `opts.verifiedEmail` lets callers that just
559
584
  * proved an inbox (invite magic link, signup OTP) record the correspondence
560
585
  * address in the same store write — see {@link setVerifiedEmail}.
@@ -977,6 +1002,21 @@ var UserService = class {
977
1002
  hasConfirmedMfaMethods(mfa) {
978
1003
  return mfa.methods.some((m) => m.confirmed);
979
1004
  }
1005
+ /** Lazily minted hash of a random password, with the configured hasher params. */
1006
+ dummyHash;
1007
+ /**
1008
+ * Verify `password` against a throwaway hash — same cost as a real check.
1009
+ * The early exits still skip the failed-attempt counter write a wrong
1010
+ * password costs (one store round trip); `AuthWorkflow`'s `'uniform'`
1011
+ * lockout disclosure spends a comparable shadow-counter write on them.
1012
+ */
1013
+ async burnPasswordHash(password) {
1014
+ this.dummyHash ??= this.hasher.hash(this.hasher.generatePassword()).catch((err) => {
1015
+ this.dummyHash = void 0;
1016
+ throw err;
1017
+ });
1018
+ await this.hasher.verify(password, await this.dummyHash);
1019
+ }
980
1020
  /**
981
1021
  * If `account.locked`: auto-unlock when the lock has expired (mutating
982
1022
  * `account` in place), or throw `LOCKED` otherwise.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/user",
3
- "version": "0.1.85",
3
+ "version": "0.2.1",
4
4
  "description": "User credential primitives for aoothjs",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -60,18 +60,18 @@
60
60
  "access": "public"
61
61
  },
62
62
  "devDependencies": {
63
- "@atscript/core": "^0.1.109",
64
- "@atscript/db": "^0.1.164",
65
- "@atscript/db-sql-tools": "^0.1.164",
66
- "@atscript/db-sqlite": "^0.1.164",
67
- "@atscript/typescript": "^0.1.109",
63
+ "@atscript/core": "^0.2.1",
64
+ "@atscript/db": "^0.1.166",
65
+ "@atscript/db-sql-tools": "^0.1.166",
66
+ "@atscript/db-sqlite": "^0.1.166",
67
+ "@atscript/typescript": "^0.2.1",
68
68
  "@types/better-sqlite3": "^7.6.13",
69
69
  "@uniqu/core": "^0.1.15",
70
70
  "better-sqlite3": "^12.6.2",
71
- "unplugin-atscript": "^0.1.109"
71
+ "unplugin-atscript": "^0.2.1"
72
72
  },
73
73
  "peerDependencies": {
74
- "@atscript/db": "^0.1.164"
74
+ "@atscript/db": "^0.1.166"
75
75
  },
76
76
  "peerDependenciesMeta": {
77
77
  "@atscript/db": {
@@ -7,7 +7,7 @@
7
7
  * Do not edit this file!
8
8
  */
9
9
 
10
- import type { TAtscriptTypeObject, TAtscriptTypeComplex, TAtscriptTypeFinal, TAtscriptTypeArray, TAtscriptAnnotatedType, TMetadataMap, Validator, TValidatorOptions } from "@atscript/typescript/utils"
10
+ import type { TAtscriptTypeObject, TAtscriptTypeComplex, TAtscriptTypeFinal, TAtscriptTypeArray, TAtscriptAnnotatedType, TAtscriptField, TMetadataMap, Validator, TValidatorOptions } from "@atscript/typescript/utils"
11
11
 
12
12
  /**
13
13
  * Atscript interface **AoothFederatedIdentity**
@@ -28,6 +28,10 @@ export interface AoothUserCredentials {
28
28
  failedLoginAttempts: number
29
29
  lastLogin: number.timestamp
30
30
  pendingInvitation?: boolean
31
+ // Id of the user who issued the pending invite (set by the invite flow)
32
+ invitedBy?: string
33
+ // Random marker of the CURRENT invite link; a re-invite rotates it, so older links answer 410
34
+ inviteNonce?: string
31
35
  // Correspondence address whose inbox the user proved (invite click, OTP, email confirm); auth-owned, not a login handle
32
36
  verifiedEmail?: string
33
37
  }
@@ -7,7 +7,7 @@
7
7
  * Do not edit this file!
8
8
  */
9
9
 
10
- import type { TAtscriptTypeObject, TAtscriptTypeComplex, TAtscriptTypeFinal, TAtscriptTypeArray, TAtscriptAnnotatedType, TMetadataMap, Validator, TValidatorOptions } from "@atscript/typescript/utils"
10
+ import type { TAtscriptTypeObject, TAtscriptTypeComplex, TAtscriptTypeFinal, TAtscriptTypeArray, TAtscriptAnnotatedType, TAtscriptField, TMetadataMap, Validator, TValidatorOptions } from "@atscript/typescript/utils"
11
11
 
12
12
  /**
13
13
  * Atscript interface **AoothUserCredentials**
@@ -31,6 +31,8 @@ export declare class AoothUserCredentials {
31
31
  failedLoginAttempts: number
32
32
  lastLogin: number /* timestamp */
33
33
  pendingInvitation?: boolean
34
+ invitedBy?: string
35
+ inviteNonce?: string
34
36
  verifiedEmail?: string
35
37
  }
36
38
  mfa: {