@aooth/user 0.1.7 → 0.1.9

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.cts CHANGED
@@ -1,4 +1,4 @@
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-BZsKtBHy.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-CI7Vgllp.cjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -71,6 +71,15 @@ interface ResolvedConfig {
71
71
  secret: string;
72
72
  };
73
73
  }
74
+ /**
75
+ * Orchestrates user credentials over a pluggable {@link UserStore}.
76
+ *
77
+ * Identity model: the stable surrogate **`id`** is the token subject. `getUser`
78
+ * and every mutation/admin method are keyed by `id` (the value carried in the
79
+ * session and returned by `useAuth().getUserId()`); only `login` (and other
80
+ * handle-driven entry points) take a `username`/`email` login handle, resolved
81
+ * via `UserStore.findByHandle`.
82
+ */
74
83
  declare class UserService<T extends object = object> {
75
84
  protected readonly store: UserStore<T>;
76
85
  protected readonly config: ResolvedConfig;
@@ -80,42 +89,59 @@ declare class UserService<T extends object = object> {
80
89
  * Creates a user with `account.active: false`. The invite workflow relies
81
90
  * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
82
91
  * 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
92
+ * don't go through invite, follow up with `activateAccount(id)` or `login()`
93
+ * will throw `UserAuthError("INACTIVE")` — which the login workflow
94
+ * deliberately re-maps to `"Invalid credentials"` to avoid account
86
95
  * enumeration, so the failure is silent client-side.
87
96
  *
97
+ * A stable `id` is minted here (server-managed surrogate, also the token
98
+ * subject) and returned on the record, so callers can `auth.issue(user.id)`
99
+ * without a re-read. Pass `id` via `extras` to override it.
100
+ *
88
101
  * @param extras Optional partial user fields merged AFTER the base
89
102
  * `UserCredentials` shape, so callers can populate consumer-specific
90
103
  * required fields (e.g. `tenantId`) without subclassing the store.
91
104
  * Because the merge is shallow and extras win, overlapping top-level
92
- * keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
93
- * pass nested objects with all required sub-fields if you intend to
94
- * override them.
105
+ * keys (`id`, `email`, `account`, `mfa`, ...) replace the defaults
106
+ * entirely — pass nested objects with all required sub-fields if you
107
+ * intend to override them.
95
108
  */
96
109
  createUser(username: string, password?: string, extras?: Partial<T>): Promise<UserCredentials & T>;
97
- getUser(username: string): Promise<UserCredentials & T>;
98
- login(username: string, password: string): Promise<LoginResult<T>>;
99
- verifyPassword(username: string, password: string): Promise<boolean>;
100
- changePassword(username: string, currentPassword: string, newPassword: string, repeatPassword?: string): Promise<void>;
101
- setPassword(username: string, newPassword: string): Promise<void>;
110
+ /** Read by the stable `id` (the token subject). */
111
+ getUser(id: string): Promise<UserCredentials & T>;
102
112
  /**
103
- * Hard-delete the user row. Returns nothing on success. Throws
104
- * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
105
- * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
113
+ * Deterministic handle resolver `username` exact, then `email` exact.
114
+ * Returns `null` when nothing matches. Maps a login/recovery handle to a row;
115
+ * `login` uses the same resolution.
106
116
  */
107
- deleteUser(username: string): Promise<void>;
117
+ findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
118
+ /**
119
+ * Permissive lookup — `id`, then `username`, then `email` (ordered, first
120
+ * match). For internal / admin / recovery callers that may hold either an id
121
+ * or a handle. NOT for the login path (use {@link login}/{@link findByHandle}).
122
+ */
123
+ findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
124
+ login(handle: string, password: string, lockoutOverride?: Partial<LockoutConfig>): Promise<LoginResult<T>>;
125
+ verifyPassword(id: string, password: string): Promise<boolean>;
126
+ changePassword(id: string, currentPassword: string, newPassword: string, repeatPassword?: string): Promise<void>;
127
+ setPassword(id: string, newPassword: string): Promise<void>;
128
+ /**
129
+ * Hard-delete the user row by `id`. Returns nothing on success. Throws
130
+ * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
131
+ * workflow's `auth/invite/cancel` to revoke a pending invitation.
132
+ */
133
+ deleteUser(id: string): Promise<void>;
108
134
  /**
109
135
  * Deep-merge `patch` into the user record (top-level fields are shallow-
110
136
  * merged; `account` / `mfa` / `password` are merged per their
111
137
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
112
138
  * Used by the invite workflow's `applyProfile` default fallback.
113
139
  */
114
- update(username: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
115
- activateAccount(username: string): Promise<void>;
116
- deactivateAccount(username: string): Promise<void>;
117
- lockAccount(username: string, reason: string, duration?: number): Promise<void>;
118
- unlockAccount(username: string): Promise<void>;
140
+ update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
141
+ activateAccount(id: string): Promise<void>;
142
+ deactivateAccount(id: string): Promise<void>;
143
+ lockAccount(id: string, reason: string, duration?: number): Promise<void>;
144
+ unlockAccount(id: string): Promise<void>;
119
145
  getLockStatus(account: UserCredentials["account"]): LockStatus;
120
146
  /**
121
147
  * Returns `true` when the user's password is older than
@@ -132,40 +158,19 @@ declare class UserService<T extends object = object> {
132
158
  isPasswordExpired(user: UserCredentials & T, now?: number): boolean;
133
159
  checkPolicies(password: string, passwordData?: PasswordData): Promise<PolicyCheckResult>;
134
160
  getTransferablePolicies(): TransferablePolicy[];
135
- addMfaMethod(username: string, method: MfaMethod): Promise<void>;
136
- confirmMfaMethod(username: string, name: string): Promise<void>;
137
- removeMfaMethod(username: string, name: string): Promise<void>;
138
- setDefaultMfaMethod(username: string, name: string): Promise<void>;
139
- setMfaAutoSend(username: string, value: boolean): Promise<void>;
161
+ addMfaMethod(id: string, method: MfaMethod): Promise<void>;
162
+ confirmMfaMethod(id: string, name: string): Promise<void>;
163
+ removeMfaMethod(id: string, name: string): Promise<void>;
164
+ setDefaultMfaMethod(id: string, name: string): Promise<void>;
165
+ setMfaAutoSend(id: string, value: boolean): Promise<void>;
140
166
  getAvailableMfaMethods(mfa: MfaData): MfaMethodInfo[];
141
- /**
142
- * Generate `count` plaintext backup codes (default 10), persist their
143
- * hashes (replacing any existing batch), and return the plaintext codes
144
- * once for the caller to deliver to the user. Plaintext is never
145
- * recoverable after this call returns.
146
- *
147
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
148
- */
149
- generateBackupCodes(username: string, count?: number): Promise<string[]>;
150
- /**
151
- * Consume a backup code: returns `true` and removes the matching hash
152
- * from storage if `code` matches a stored backup code; returns `false`
153
- * if no match (without modifying storage). Single-use is enforced by
154
- * optimistic-concurrency CAS on the version column — concurrent consumes
155
- * of the same code race fairly and only one wins; the loser re-reads,
156
- * finds the hash already removed, and returns `false`.
157
- *
158
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
159
- * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
160
- */
161
- consumeBackupCode(username: string, code: string): Promise<boolean>;
162
167
  /**
163
168
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
164
169
  * Failures bump the same `failedLoginAttempts` counter as `login` so an
165
170
  * attacker who knows the password but not the TOTP gets `lockout.threshold`
166
171
  * total tries across BOTH factors, not `2 * threshold`.
167
172
  */
168
- verifyMfa(username: string, code: string, config?: TotpConfig): Promise<void>;
173
+ verifyMfa(id: string, code: string, config?: TotpConfig, lockoutOverride?: Partial<LockoutConfig>): Promise<void>;
169
174
  /**
170
175
  * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
171
176
  * enrollment. Differs from `verifyMfa`:
@@ -177,7 +182,7 @@ declare class UserService<T extends object = object> {
177
182
  * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
178
183
  * totp method; MFA_INVALID on wrong code.
179
184
  */
180
- verifyTotpSetupCode(username: string, code: string, config?: TotpConfig): Promise<void>;
185
+ verifyTotpSetupCode(id: string, code: string, config?: TotpConfig): Promise<void>;
181
186
  getPasswordHasher(): PasswordHasher;
182
187
  getConfig(): Readonly<ResolvedConfig>;
183
188
  /**
@@ -194,19 +199,19 @@ declare class UserService<T extends object = object> {
194
199
  * write — the array shape is preserved end-to-end so DB adapters with a
195
200
  * merge strategy replace the whole array.
196
201
  */
197
- addTrustedDevice(username: string, record: TrustedDeviceRecord): Promise<void>;
202
+ addTrustedDevice(id: string, record: TrustedDeviceRecord): Promise<void>;
198
203
  /**
199
204
  * Returns true when the supplied token (a) signs against the user+ip with
200
205
  * the configured secret, AND (b) matches a persisted record that is still
201
206
  * within its expiry window and whose bound IP (if any) matches.
202
207
  */
203
- verifyTrustedDevice(username: string, token: string, ip?: string): Promise<boolean>;
208
+ verifyTrustedDevice(userId: string, token: string, ip?: string): Promise<boolean>;
204
209
  /**
205
210
  * Remove a specific trust record from the user. No-op when the record is
206
211
  * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
207
212
  */
208
- revokeTrustedDevice(username: string, token: string): Promise<void>;
209
- listTrustedDevices(username: string): Promise<TrustedDeviceRecord[]>;
213
+ revokeTrustedDevice(id: string, token: string): Promise<void>;
214
+ listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
210
215
  private requireDeviceTrustSecret;
211
216
  private applyPasswordChange;
212
217
  private hasConfirmedMfaMethods;
@@ -220,20 +225,78 @@ declare class UserService<T extends object = object> {
220
225
  * and always throw `errorCode` (with `details.lockEnds` when the lockout
221
226
  * just tripped). Used by both `login` and `verifyMfa` so the two factors
222
227
  * share one counter.
228
+ *
229
+ * `lockoutOverride` lets a caller (e.g. a workflow policy resolver) force a
230
+ * different posture for THIS lock — notably `{ duration: 0 }` to make the
231
+ * lock permanent (admin-/recovery-lift only) instead of timed. Unset fields
232
+ * fall back to `this.config.lockout`.
223
233
  */
224
234
  private incrementAndMaybeLock;
225
235
  }
226
236
  //#endregion
227
237
  //#region src/store/memory.d.ts
228
238
  declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
239
+ /** Keyed by the stable surrogate `id` (the token subject). */
229
240
  private store;
230
- constructor(seed?: Record<string, UserCredentials & T>);
231
- exists(username: string): Promise<boolean>;
232
- findByUsername(username: string): Promise<(UserCredentials & T) | null>;
241
+ /**
242
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
243
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
244
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
245
+ * enforced unique by `create`. Empty when no handles are configured (login by
246
+ * email/phone unavailable).
247
+ */
248
+ private readonly handleFields;
249
+ /**
250
+ * Optional seed. The map is keyed by each record's `id`; a record missing an
251
+ * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
252
+ * keys are ignored — identity is the record's `id`.
253
+ *
254
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
255
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
256
+ */
257
+ constructor(seed?: Record<string, UserCredentials & T>, opts?: {
258
+ handleFields?: string[];
259
+ });
260
+ exists(handle: string): Promise<boolean>;
261
+ findById(id: string): Promise<(UserCredentials & T) | null>;
262
+ findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
263
+ findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
233
264
  create(data: UserCredentials & T): Promise<void>;
234
- update(username: string, update: UserStoreUpdate): Promise<boolean>;
235
- delete(username: string): Promise<boolean>;
236
- withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
265
+ /**
266
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
267
+ * one of `rec`'s handle-column values the in-memory mirror of the
268
+ * atscript-db store's unique index, so `create` and `update` enforce the same
269
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
270
+ * values are string columns; a non-string is never a collision.
271
+ */
272
+ private assertHandlesUnique;
273
+ update(id: string, update: UserStoreUpdate): Promise<boolean>;
274
+ delete(id: string): Promise<boolean>;
275
+ withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
276
+ }
277
+ //#endregion
278
+ //#region src/store/federated-identity-store-memory.d.ts
279
+ interface FederatedIdentityStoreMemoryOptions {
280
+ /** Injectable clock for deterministic `linkedAt` / `lastLoginAt`. Defaults to `Date.now`. */
281
+ clock?: () => number;
282
+ }
283
+ /**
284
+ * In-memory {@link FederatedIdentityStore} — the offline-testable reference
285
+ * impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
286
+ * `structuredClone` on every read/write isolates callers from later mutation
287
+ * (mirrors `UserStoreMemory`).
288
+ */
289
+ declare class FederatedIdentityStoreMemory extends FederatedIdentityStore {
290
+ /** Keyed by `compositeKey(provider, subject)`. */
291
+ private store;
292
+ private clock;
293
+ constructor(opts?: FederatedIdentityStoreMemoryOptions);
294
+ find(provider: string, subject: string): Promise<FederatedIdentity | null>;
295
+ listForUser(userId: string): Promise<FederatedIdentity[]>;
296
+ link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
297
+ unlink(provider: string, subject: string): Promise<boolean>;
298
+ touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
299
+ deleteAllForUser(userId: string): Promise<number>;
237
300
  }
238
301
  //#endregion
239
302
  //#region src/password/policies.d.ts
@@ -274,23 +337,10 @@ declare function hashMfaCode(code: string): string;
274
337
  */
275
338
  declare function verifyMfaCode(submitted: string, expectedHash: string): boolean;
276
339
  //#endregion
277
- //#region src/mfa/backup-codes.d.ts
278
- /**
279
- * Generate `count` cryptographically-random backup codes (default 10).
280
- *
281
- * Format: 10 characters from the 31-char safe alphabet (uppercase letters +
282
- * digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
283
- *
284
- * Returns plaintext codes for the caller to deliver to the user — these
285
- * should be hashed via {@link hashMfaCode} before persistence and never
286
- * shown to the user again.
287
- */
288
- declare function generateBackupCodePlaintext(count?: number): string[];
289
- //#endregion
290
340
  //#region src/utils.d.ts
291
341
  declare function maskEmail(email: string): string;
292
342
  declare function maskPhone(phone: string): string;
293
343
  declare function maskMfaValue(method: MfaMethod): string;
294
344
  declare function setAtPath(obj: object, path: string, value: unknown): void;
295
345
  //#endregion
296
- 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 };
346
+ export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, 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, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
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-62LCSa8q.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-CI7Vgllp.mjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -71,6 +71,15 @@ interface ResolvedConfig {
71
71
  secret: string;
72
72
  };
73
73
  }
74
+ /**
75
+ * Orchestrates user credentials over a pluggable {@link UserStore}.
76
+ *
77
+ * Identity model: the stable surrogate **`id`** is the token subject. `getUser`
78
+ * and every mutation/admin method are keyed by `id` (the value carried in the
79
+ * session and returned by `useAuth().getUserId()`); only `login` (and other
80
+ * handle-driven entry points) take a `username`/`email` login handle, resolved
81
+ * via `UserStore.findByHandle`.
82
+ */
74
83
  declare class UserService<T extends object = object> {
75
84
  protected readonly store: UserStore<T>;
76
85
  protected readonly config: ResolvedConfig;
@@ -80,42 +89,59 @@ declare class UserService<T extends object = object> {
80
89
  * Creates a user with `account.active: false`. The invite workflow relies
81
90
  * on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
82
91
  * 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
92
+ * don't go through invite, follow up with `activateAccount(id)` or `login()`
93
+ * will throw `UserAuthError("INACTIVE")` — which the login workflow
94
+ * deliberately re-maps to `"Invalid credentials"` to avoid account
86
95
  * enumeration, so the failure is silent client-side.
87
96
  *
97
+ * A stable `id` is minted here (server-managed surrogate, also the token
98
+ * subject) and returned on the record, so callers can `auth.issue(user.id)`
99
+ * without a re-read. Pass `id` via `extras` to override it.
100
+ *
88
101
  * @param extras Optional partial user fields merged AFTER the base
89
102
  * `UserCredentials` shape, so callers can populate consumer-specific
90
103
  * required fields (e.g. `tenantId`) without subclassing the store.
91
104
  * Because the merge is shallow and extras win, overlapping top-level
92
- * keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
93
- * pass nested objects with all required sub-fields if you intend to
94
- * override them.
105
+ * keys (`id`, `email`, `account`, `mfa`, ...) replace the defaults
106
+ * entirely — pass nested objects with all required sub-fields if you
107
+ * intend to override them.
95
108
  */
96
109
  createUser(username: string, password?: string, extras?: Partial<T>): Promise<UserCredentials & T>;
97
- getUser(username: string): Promise<UserCredentials & T>;
98
- login(username: string, password: string): Promise<LoginResult<T>>;
99
- verifyPassword(username: string, password: string): Promise<boolean>;
100
- changePassword(username: string, currentPassword: string, newPassword: string, repeatPassword?: string): Promise<void>;
101
- setPassword(username: string, newPassword: string): Promise<void>;
110
+ /** Read by the stable `id` (the token subject). */
111
+ getUser(id: string): Promise<UserCredentials & T>;
102
112
  /**
103
- * Hard-delete the user row. Returns nothing on success. Throws
104
- * `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
105
- * invite workflow's `auth/invite/cancel` to revoke a pending invitation.
113
+ * Deterministic handle resolver `username` exact, then `email` exact.
114
+ * Returns `null` when nothing matches. Maps a login/recovery handle to a row;
115
+ * `login` uses the same resolution.
106
116
  */
107
- deleteUser(username: string): Promise<void>;
117
+ findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
118
+ /**
119
+ * Permissive lookup — `id`, then `username`, then `email` (ordered, first
120
+ * match). For internal / admin / recovery callers that may hold either an id
121
+ * or a handle. NOT for the login path (use {@link login}/{@link findByHandle}).
122
+ */
123
+ findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
124
+ login(handle: string, password: string, lockoutOverride?: Partial<LockoutConfig>): Promise<LoginResult<T>>;
125
+ verifyPassword(id: string, password: string): Promise<boolean>;
126
+ changePassword(id: string, currentPassword: string, newPassword: string, repeatPassword?: string): Promise<void>;
127
+ setPassword(id: string, newPassword: string): Promise<void>;
128
+ /**
129
+ * Hard-delete the user row by `id`. Returns nothing on success. Throws
130
+ * `UserAuthError("NOT_FOUND")` when no row matches. Used by the invite
131
+ * workflow's `auth/invite/cancel` to revoke a pending invitation.
132
+ */
133
+ deleteUser(id: string): Promise<void>;
108
134
  /**
109
135
  * Deep-merge `patch` into the user record (top-level fields are shallow-
110
136
  * merged; `account` / `mfa` / `password` are merged per their
111
137
  * `@db.patch.strategy 'merge'` declaration). Returns the patched record.
112
138
  * Used by the invite workflow's `applyProfile` default fallback.
113
139
  */
114
- update(username: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
115
- activateAccount(username: string): Promise<void>;
116
- deactivateAccount(username: string): Promise<void>;
117
- lockAccount(username: string, reason: string, duration?: number): Promise<void>;
118
- unlockAccount(username: string): Promise<void>;
140
+ update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
141
+ activateAccount(id: string): Promise<void>;
142
+ deactivateAccount(id: string): Promise<void>;
143
+ lockAccount(id: string, reason: string, duration?: number): Promise<void>;
144
+ unlockAccount(id: string): Promise<void>;
119
145
  getLockStatus(account: UserCredentials["account"]): LockStatus;
120
146
  /**
121
147
  * Returns `true` when the user's password is older than
@@ -132,40 +158,19 @@ declare class UserService<T extends object = object> {
132
158
  isPasswordExpired(user: UserCredentials & T, now?: number): boolean;
133
159
  checkPolicies(password: string, passwordData?: PasswordData): Promise<PolicyCheckResult>;
134
160
  getTransferablePolicies(): TransferablePolicy[];
135
- addMfaMethod(username: string, method: MfaMethod): Promise<void>;
136
- confirmMfaMethod(username: string, name: string): Promise<void>;
137
- removeMfaMethod(username: string, name: string): Promise<void>;
138
- setDefaultMfaMethod(username: string, name: string): Promise<void>;
139
- setMfaAutoSend(username: string, value: boolean): Promise<void>;
161
+ addMfaMethod(id: string, method: MfaMethod): Promise<void>;
162
+ confirmMfaMethod(id: string, name: string): Promise<void>;
163
+ removeMfaMethod(id: string, name: string): Promise<void>;
164
+ setDefaultMfaMethod(id: string, name: string): Promise<void>;
165
+ setMfaAutoSend(id: string, value: boolean): Promise<void>;
140
166
  getAvailableMfaMethods(mfa: MfaData): MfaMethodInfo[];
141
- /**
142
- * Generate `count` plaintext backup codes (default 10), persist their
143
- * hashes (replacing any existing batch), and return the plaintext codes
144
- * once for the caller to deliver to the user. Plaintext is never
145
- * recoverable after this call returns.
146
- *
147
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
148
- */
149
- generateBackupCodes(username: string, count?: number): Promise<string[]>;
150
- /**
151
- * Consume a backup code: returns `true` and removes the matching hash
152
- * from storage if `code` matches a stored backup code; returns `false`
153
- * if no match (without modifying storage). Single-use is enforced by
154
- * optimistic-concurrency CAS on the version column — concurrent consumes
155
- * of the same code race fairly and only one wins; the loser re-reads,
156
- * finds the hash already removed, and returns `false`.
157
- *
158
- * Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
159
- * Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
160
- */
161
- consumeBackupCode(username: string, code: string): Promise<boolean>;
162
167
  /**
163
168
  * Verify a TOTP code against the user's confirmed `totp` MFA method.
164
169
  * Failures bump the same `failedLoginAttempts` counter as `login` so an
165
170
  * attacker who knows the password but not the TOTP gets `lockout.threshold`
166
171
  * total tries across BOTH factors, not `2 * threshold`.
167
172
  */
168
- verifyMfa(username: string, code: string, config?: TotpConfig): Promise<void>;
173
+ verifyMfa(id: string, code: string, config?: TotpConfig, lockoutOverride?: Partial<LockoutConfig>): Promise<void>;
169
174
  /**
170
175
  * Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
171
176
  * enrollment. Differs from `verifyMfa`:
@@ -177,7 +182,7 @@ declare class UserService<T extends object = object> {
177
182
  * Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
178
183
  * totp method; MFA_INVALID on wrong code.
179
184
  */
180
- verifyTotpSetupCode(username: string, code: string, config?: TotpConfig): Promise<void>;
185
+ verifyTotpSetupCode(id: string, code: string, config?: TotpConfig): Promise<void>;
181
186
  getPasswordHasher(): PasswordHasher;
182
187
  getConfig(): Readonly<ResolvedConfig>;
183
188
  /**
@@ -194,19 +199,19 @@ declare class UserService<T extends object = object> {
194
199
  * write — the array shape is preserved end-to-end so DB adapters with a
195
200
  * merge strategy replace the whole array.
196
201
  */
197
- addTrustedDevice(username: string, record: TrustedDeviceRecord): Promise<void>;
202
+ addTrustedDevice(id: string, record: TrustedDeviceRecord): Promise<void>;
198
203
  /**
199
204
  * Returns true when the supplied token (a) signs against the user+ip with
200
205
  * the configured secret, AND (b) matches a persisted record that is still
201
206
  * within its expiry window and whose bound IP (if any) matches.
202
207
  */
203
- verifyTrustedDevice(username: string, token: string, ip?: string): Promise<boolean>;
208
+ verifyTrustedDevice(userId: string, token: string, ip?: string): Promise<boolean>;
204
209
  /**
205
210
  * Remove a specific trust record from the user. No-op when the record is
206
211
  * absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
207
212
  */
208
- revokeTrustedDevice(username: string, token: string): Promise<void>;
209
- listTrustedDevices(username: string): Promise<TrustedDeviceRecord[]>;
213
+ revokeTrustedDevice(id: string, token: string): Promise<void>;
214
+ listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
210
215
  private requireDeviceTrustSecret;
211
216
  private applyPasswordChange;
212
217
  private hasConfirmedMfaMethods;
@@ -220,20 +225,78 @@ declare class UserService<T extends object = object> {
220
225
  * and always throw `errorCode` (with `details.lockEnds` when the lockout
221
226
  * just tripped). Used by both `login` and `verifyMfa` so the two factors
222
227
  * share one counter.
228
+ *
229
+ * `lockoutOverride` lets a caller (e.g. a workflow policy resolver) force a
230
+ * different posture for THIS lock — notably `{ duration: 0 }` to make the
231
+ * lock permanent (admin-/recovery-lift only) instead of timed. Unset fields
232
+ * fall back to `this.config.lockout`.
223
233
  */
224
234
  private incrementAndMaybeLock;
225
235
  }
226
236
  //#endregion
227
237
  //#region src/store/memory.d.ts
228
238
  declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
239
+ /** Keyed by the stable surrogate `id` (the token subject). */
229
240
  private store;
230
- constructor(seed?: Record<string, UserCredentials & T>);
231
- exists(username: string): Promise<boolean>;
232
- findByUsername(username: string): Promise<(UserCredentials & T) | null>;
241
+ /**
242
+ * Ordered secondary handle fields (e.g. email then phone) resolved from the
243
+ * model's `@aooth.user.*` annotations by the wiring layer (`handleFields` of
244
+ * `getAoothUserHandleSpec`). Tried by `findByHandle` after `username`, and
245
+ * enforced unique by `create`. Empty when no handles are configured (login by
246
+ * email/phone unavailable).
247
+ */
248
+ private readonly handleFields;
249
+ /**
250
+ * Optional seed. The map is keyed by each record's `id`; a record missing an
251
+ * `id` gets one minted (mirrors the DB `@db.default.uuid`). The seed object's
252
+ * keys are ignored — identity is the record's `id`.
253
+ *
254
+ * `opts.handleFields` names the ordered secondary login handles (mirroring
255
+ * `UsersStoreAtscriptDb`); omit it for username-only resolution.
256
+ */
257
+ constructor(seed?: Record<string, UserCredentials & T>, opts?: {
258
+ handleFields?: string[];
259
+ });
260
+ exists(handle: string): Promise<boolean>;
261
+ findById(id: string): Promise<(UserCredentials & T) | null>;
262
+ findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
263
+ findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
233
264
  create(data: UserCredentials & T): Promise<void>;
234
- update(username: string, update: UserStoreUpdate): Promise<boolean>;
235
- delete(username: string): Promise<boolean>;
236
- withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
265
+ /**
266
+ * Throw `ALREADY_EXISTS` if any record other than `excludeId` already holds
267
+ * one of `rec`'s handle-column values the in-memory mirror of the
268
+ * atscript-db store's unique index, so `create` and `update` enforce the same
269
+ * collision contract (which `promote-to-handle` swallows best-effort). Handle
270
+ * values are string columns; a non-string is never a collision.
271
+ */
272
+ private assertHandlesUnique;
273
+ update(id: string, update: UserStoreUpdate): Promise<boolean>;
274
+ delete(id: string): Promise<boolean>;
275
+ withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
276
+ }
277
+ //#endregion
278
+ //#region src/store/federated-identity-store-memory.d.ts
279
+ interface FederatedIdentityStoreMemoryOptions {
280
+ /** Injectable clock for deterministic `linkedAt` / `lastLoginAt`. Defaults to `Date.now`. */
281
+ clock?: () => number;
282
+ }
283
+ /**
284
+ * In-memory {@link FederatedIdentityStore} — the offline-testable reference
285
+ * impl (RFC IDP.md §9). Keyed by the composite `(provider, subject)`;
286
+ * `structuredClone` on every read/write isolates callers from later mutation
287
+ * (mirrors `UserStoreMemory`).
288
+ */
289
+ declare class FederatedIdentityStoreMemory extends FederatedIdentityStore {
290
+ /** Keyed by `compositeKey(provider, subject)`. */
291
+ private store;
292
+ private clock;
293
+ constructor(opts?: FederatedIdentityStoreMemoryOptions);
294
+ find(provider: string, subject: string): Promise<FederatedIdentity | null>;
295
+ listForUser(userId: string): Promise<FederatedIdentity[]>;
296
+ link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
297
+ unlink(provider: string, subject: string): Promise<boolean>;
298
+ touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
299
+ deleteAllForUser(userId: string): Promise<number>;
237
300
  }
238
301
  //#endregion
239
302
  //#region src/password/policies.d.ts
@@ -274,23 +337,10 @@ declare function hashMfaCode(code: string): string;
274
337
  */
275
338
  declare function verifyMfaCode(submitted: string, expectedHash: string): boolean;
276
339
  //#endregion
277
- //#region src/mfa/backup-codes.d.ts
278
- /**
279
- * Generate `count` cryptographically-random backup codes (default 10).
280
- *
281
- * Format: 10 characters from the 31-char safe alphabet (uppercase letters +
282
- * digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
283
- *
284
- * Returns plaintext codes for the caller to deliver to the user — these
285
- * should be hashed via {@link hashMfaCode} before persistence and never
286
- * shown to the user again.
287
- */
288
- declare function generateBackupCodePlaintext(count?: number): string[];
289
- //#endregion
290
340
  //#region src/utils.d.ts
291
341
  declare function maskEmail(email: string): string;
292
342
  declare function maskPhone(phone: string): string;
293
343
  declare function maskMfaValue(method: MfaMethod): string;
294
344
  declare function setAtPath(obj: object, path: string, value: unknown): void;
295
345
  //#endregion
296
- 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 };
346
+ export { type AccountData, type DeepPartial, type FederatedIdentity, FederatedIdentityStore, FederatedIdentityStoreMemory, type FederatedIdentityStoreMemoryOptions, type FederatedProfileSnapshot, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type NewFederatedIdentity, 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, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };