@aooth/user 0.1.6 → 0.1.8

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.
@@ -0,0 +1,378 @@
1
+ //#region src/types.d.ts
2
+ interface UserCredentials {
3
+ id: string;
4
+ username: string;
5
+ /**
6
+ * Unique login/contact handle. Indexed `@db.index.unique 'email_idx'` on the
7
+ * `AoothUserCredentials` model so it is independently unique from `username`
8
+ * and resolvable via `UserStore.findByHandle` / `findByIdentifier`. Optional
9
+ * because not every deployment populates it (and the invite flow sets
10
+ * `username := email`).
11
+ */
12
+ email?: string;
13
+ /**
14
+ * Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
15
+ * on every successful write; checked against `UserStoreUpdate.expectedVersion`
16
+ * for CAS. Callers MUST NOT write it directly — atscript-db rejects direct
17
+ * writes with `DbError("VERSION_COLUMN_WRITE")`. Optional in TS so pre-OCC
18
+ * fixtures keep compiling; the store seeds `0` on insert.
19
+ */
20
+ version?: number;
21
+ password: PasswordData;
22
+ account: AccountData;
23
+ mfa: MfaData;
24
+ /**
25
+ * Persisted device-trust records ("remember this device, skip MFA next
26
+ * time"). Managed by `UserService.{issue,add,verify,revoke,list}TrustedDevice`.
27
+ * Absent when the user has never opted in.
28
+ */
29
+ trustedDevices?: TrustedDeviceRecord[];
30
+ }
31
+ interface TrustedDeviceRecord {
32
+ /** `<raw>.<sig>` — what we hand back to the consumer and what they round-trip. */
33
+ token: string;
34
+ /** Bound IP — set when `deviceTrust.bindsTo === 'cookie+ip'`. */
35
+ ip?: string;
36
+ issuedAt: number;
37
+ expiresAt: number;
38
+ /** Optional human-readable label (e.g. user-agent summary). */
39
+ name?: string;
40
+ }
41
+ interface PasswordData {
42
+ /** Self-describing scrypt hash: $scrypt$N=...,r=...,p=...,l=...$salt$hash */
43
+ hash: string;
44
+ /** Previous password hashes (self-describing strings) */
45
+ history: string[];
46
+ lastChanged: number;
47
+ /** True when password was system-generated and user hasn't set their own */
48
+ isInitial: boolean;
49
+ }
50
+ interface AccountData {
51
+ active: boolean;
52
+ locked: boolean;
53
+ lockReason: string;
54
+ /** 0 = permanent lock, >0 = timestamp (ms) when lock expires */
55
+ lockEnds: number;
56
+ failedLoginAttempts: number;
57
+ lastLogin: number;
58
+ /**
59
+ * True while the user record exists from an admin-issued invite but the
60
+ * invitee has not yet accepted (set password + activate). Used by
61
+ * `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
62
+ * power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
63
+ * the invite has been accepted.
64
+ */
65
+ pendingInvitation?: boolean;
66
+ }
67
+ interface MfaData {
68
+ /** Registered MFA methods */
69
+ methods: MfaMethod[];
70
+ /** Name of the default MFA method */
71
+ defaultMethod: string;
72
+ /** Auto-send MFA challenge on login */
73
+ autoSend: boolean;
74
+ }
75
+ interface MfaMethod {
76
+ /** Method name: 'email', 'sms', 'totp' */
77
+ name: string;
78
+ /** Whether this method has been verified/confirmed */
79
+ confirmed: boolean;
80
+ /** The method's value: email address, phone number, or TOTP secret */
81
+ value: string;
82
+ /**
83
+ * Last HOTP counter accepted for this method (TOTP only). Server-managed
84
+ * replay guard — `verifyMfa` rejects any code whose matched counter is
85
+ * `<= lastUsedWindow`. Never written from user-facing input.
86
+ */
87
+ lastUsedWindow?: number;
88
+ }
89
+ interface UserServiceConfig {
90
+ password?: PasswordConfig;
91
+ lockout?: LockoutConfig;
92
+ /** Injectable clock for testability. Defaults to Date.now */
93
+ clock?: () => number;
94
+ /**
95
+ * Device-trust config. Required (with a non-empty `secret`) when any
96
+ * `issueTrustedDevice` / `verifyTrustedDevice` API is called; the methods
97
+ * throw clearly when invoked without it.
98
+ */
99
+ deviceTrust?: {
100
+ /** HMAC-SHA256 signing secret for trust-device tokens. */secret: string;
101
+ };
102
+ }
103
+ interface PasswordConfig {
104
+ /** Pepper string prepended to password before hashing */
105
+ pepper?: string;
106
+ /** Number of historical hashes to retain (0 = disabled) */
107
+ historyLength?: number;
108
+ /** scrypt cost parameter N (default 16384) */
109
+ scryptN?: number;
110
+ /** scrypt block size r (default 8) */
111
+ scryptR?: number;
112
+ /** scrypt parallelism p (default 1) */
113
+ scryptP?: number;
114
+ /** Hash output length in bytes (default 64) */
115
+ keyLength?: number;
116
+ /** Password policy rules */
117
+ policies?: (PasswordPolicyDef | PasswordPolicyInstance)[];
118
+ /**
119
+ * Force re-set of an existing password after this many ms since
120
+ * `password.lastChanged`. `0` / undefined disables expiry. Read by
121
+ * `UserService.isPasswordExpired()`; consulted by `@aooth/auth-moost`
122
+ * `LoginWorkflow`'s forced-change branch when `guards.passwordExpiry`
123
+ * is true (the default).
124
+ */
125
+ maxAgeMs?: number;
126
+ }
127
+ interface LockoutConfig {
128
+ /** Lock after this many failed attempts (0 = disabled) */
129
+ threshold?: number;
130
+ /** Lock duration in ms (0 = permanent) */
131
+ duration?: number;
132
+ }
133
+ interface PasswordPolicyDef {
134
+ /**
135
+ * Backend evaluator. Function only — executed directly with no sandbox.
136
+ * String rules were removed: `@prostojs/ftring`'s sandbox does NOT block
137
+ * prototype-chain escapes (`constructor.constructor("return process")()`,
138
+ * `__proto__.x = ...`), so accepting strings was an RCE vector. Authors
139
+ * use `definePasswordPolicy({ rule, args })` to get both this fn AND the
140
+ * serialized form for free.
141
+ */
142
+ rule: PasswordPolicyEvalFn;
143
+ /**
144
+ * Pre-baked function-literal text shipped to clients for cross-tier
145
+ * validation via `getTransferablePolicies()`. Authored as
146
+ * `(v) => (${ruleSource})(v, ${args.map(JSON.stringify).join(', ')})` by
147
+ * `definePasswordPolicy`. Absent → the policy is backend-only (frontend
148
+ * skips it; server-side check remains authoritative).
149
+ */
150
+ serialized?: string;
151
+ description?: string;
152
+ errorMessage?: string;
153
+ }
154
+ type PasswordPolicyEvalFn = (password: string, context?: PasswordPolicyContext) => boolean | Promise<boolean>;
155
+ interface PasswordPolicyContext {
156
+ passwordData?: PasswordData;
157
+ passwordConfig?: PasswordConfig;
158
+ }
159
+ /** Interface satisfied by the PasswordPolicy class (avoids circular import) */
160
+ interface PasswordPolicyInstance extends PasswordPolicyDef {
161
+ evaluate(password: string, context?: PasswordPolicyContext): boolean | Promise<boolean>;
162
+ transferable: boolean;
163
+ }
164
+ type DeepPartial<T> = { [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P] };
165
+ interface UserStoreUpdate {
166
+ /** Partial object with fields to set (deep-merged) */
167
+ set?: DeepPartial<UserCredentials>;
168
+ /** Dot-paths to atomically increment: e.g. {'account.failedLoginAttempts': 1} */
169
+ inc?: Record<string, number>;
170
+ /**
171
+ * Optimistic concurrency control: when supplied, the store applies the
172
+ * update iff the row's current `version` equals this value. On mismatch
173
+ * the store returns `false` (same shape as "not found") and does NOT
174
+ * mutate. Service callers treat both states as "stale read, retry".
175
+ */
176
+ expectedVersion?: number;
177
+ }
178
+ 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";
179
+ interface LoginResult<T extends object = object> {
180
+ user: UserCredentials & T;
181
+ /** Whether MFA verification is required before granting full access */
182
+ mfaRequired: boolean;
183
+ }
184
+ interface LockStatus {
185
+ locked: boolean;
186
+ /** True when lock has a non-zero lockEnds that is in the past */
187
+ expired: boolean;
188
+ reason: string;
189
+ lockEnds: number;
190
+ }
191
+ interface PolicyCheckResult {
192
+ passed: boolean;
193
+ policies: {
194
+ description: string;
195
+ passed: boolean;
196
+ }[];
197
+ errors: string[];
198
+ }
199
+ interface TransferablePolicy {
200
+ rule: string;
201
+ description?: string;
202
+ errorMessage?: string;
203
+ }
204
+ interface MfaMethodInfo {
205
+ name: string;
206
+ isDefault: boolean;
207
+ masked: string;
208
+ }
209
+ interface TotpConfig {
210
+ /** Time step in seconds (default 30) */
211
+ period?: number;
212
+ /** Number of digits in the code (default 6) */
213
+ digits?: number;
214
+ /** Verification window — number of steps to check on each side (default 1) */
215
+ window?: number;
216
+ /** Injectable clock for testability */
217
+ clock?: () => number;
218
+ }
219
+ //#endregion
220
+ //#region src/store/user-store.d.ts
221
+ interface WithCasOptions {
222
+ /**
223
+ * Total attempts (1 initial + retries). Default `2` = one retry. Each
224
+ * attempt re-reads the row so the mutator runs against fresh state — that
225
+ * is the whole point of retry under OCC. Bump for high-contention writers
226
+ * (bulk admin scripts); leave at default for normal per-user request flow.
227
+ */
228
+ maxAttempts?: number;
229
+ }
230
+ /**
231
+ * Storage seam for user credentials, keyed by the stable surrogate **`id`**
232
+ * (the token subject). Reads come in three flavours:
233
+ *
234
+ * - `findById` — strict, by the surrogate id; the canonical identity read used
235
+ * by authenticated flows that resolve the session subject (`getUserId()`).
236
+ * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
237
+ * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
238
+ * `username`, then `email`).
239
+ *
240
+ * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
241
+ */
242
+ declare abstract class UserStore<T extends object = object> {
243
+ /** True when a user with this login handle (`username`) exists. */
244
+ abstract exists(handle: string): Promise<boolean>;
245
+ /**
246
+ * Strict read by the stable surrogate `id` — the token subject. Authenticated
247
+ * flows resolve the session subject (`useAuth().getUserId()`) through this.
248
+ */
249
+ abstract findById(id: string): Promise<(UserCredentials & T) | null>;
250
+ /**
251
+ * Deterministic LOGIN resolver: matches `username` exactly, then `email`
252
+ * exactly (in that order). Intentionally NOT a permissive `$or` — `id`,
253
+ * `username`, and `email` are all strings, so a permissive match could
254
+ * silently resolve a value that is one user's username and another's email
255
+ * to an arbitrary account.
256
+ */
257
+ abstract findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
258
+ /**
259
+ * Permissive lookup for internal / admin / recovery callers: `id`, then
260
+ * `username`, then `email` (ordered, first match). NOT for the login path —
261
+ * use `findByHandle` there.
262
+ */
263
+ abstract findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
264
+ abstract create(data: UserCredentials & T): Promise<void>;
265
+ /** Apply a patch to the row identified by the stable `id`. */
266
+ abstract update(id: string, update: UserStoreUpdate): Promise<boolean>;
267
+ /**
268
+ * Hard-delete the row by `id`. Returns `true` when a row was removed, `false`
269
+ * when the id was not found.
270
+ */
271
+ abstract delete(id: string): Promise<boolean>;
272
+ /**
273
+ * Run a read-modify-write cycle under optimistic concurrency, keyed by `id`.
274
+ * Each attempt re-reads (via `findById`), calls `mutator`, and applies the
275
+ * returned patch under CAS (`expectedVersion = current.version`). On CAS miss
276
+ * the cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null`
277
+ * to exit early without writing (race-loser "nothing left to do").
278
+ *
279
+ * Throws `UserAuthError("NOT_FOUND")` when no row matches `id`, or
280
+ * `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors thrown
281
+ * from inside `mutator` propagate immediately without retry.
282
+ */
283
+ abstract withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
284
+ }
285
+ //#endregion
286
+ //#region src/store/federated-identity-store.d.ts
287
+ /**
288
+ * Display fields snapshotted from an IdP profile onto a federated-identity row.
289
+ * Refreshed on each login via {@link FederatedIdentityStore.touchLogin}; never
290
+ * a join key (the stable join is `(provider, subject)`). Phase-2's
291
+ * `NormalizedProfile` (in `@aooth/idp`) is a structural superset of this — it
292
+ * is declared HERE rather than imported so `@aooth/user` keeps no dependency on
293
+ * the layer above it.
294
+ */
295
+ interface FederatedProfileSnapshot {
296
+ email?: string;
297
+ emailVerified?: boolean;
298
+ displayName?: string;
299
+ avatarUrl?: string;
300
+ }
301
+ /**
302
+ * Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
303
+ * partial profile (e.g. Apple omitting the email on a repeat login) never
304
+ * overwrites a stored value with `undefined`. Shared by every
305
+ * {@link FederatedIdentityStore} impl.
306
+ */
307
+ declare function pickDefinedProfile(src: FederatedProfileSnapshot): FederatedProfileSnapshot;
308
+ /**
309
+ * A persisted federated-identity row: one external-provider account
310
+ * (`provider` + the IdP's stable `subject`) linked to exactly one aooth user
311
+ * (`userId` = the user's surrogate `id`). Mirrors the shipped
312
+ * `AoothFederatedIdentity` `.as` model by construction.
313
+ */
314
+ interface FederatedIdentity extends FederatedProfileSnapshot {
315
+ /** Surrogate PK. Server-assigned (`@db.default.uuid` / `randomUUID`). */
316
+ id: string;
317
+ provider: string;
318
+ subject: string;
319
+ /** Owner — the user's stable surrogate `id`. */
320
+ userId: string;
321
+ /** When the link was first created. */
322
+ linkedAt: number;
323
+ /** Last federated login through this identity; absent until first `touchLogin`. */
324
+ lastLoginAt?: number;
325
+ }
326
+ /**
327
+ * Input to {@link FederatedIdentityStore.link} — the identity keys + owner plus
328
+ * an optional first-login profile snapshot. `id` and `linkedAt` are assigned by
329
+ * the store.
330
+ */
331
+ interface NewFederatedIdentity extends FederatedProfileSnapshot {
332
+ provider: string;
333
+ subject: string;
334
+ userId: string;
335
+ }
336
+ /**
337
+ * Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
338
+ * lookup key is the composite `(provider, subject)`; a user may own many rows
339
+ * (one per linked provider account), so `userId` reads return a list.
340
+ *
341
+ * In-memory + atscript-db implementations ship alongside; the abstract surface
342
+ * keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
343
+ */
344
+ declare abstract class FederatedIdentityStore {
345
+ /** Resolve a provider account to its linked row, or `null`. The federated-login hot path. */
346
+ abstract find(provider: string, subject: string): Promise<FederatedIdentity | null>;
347
+ /** All identities linked to a user — the "connected accounts" view. */
348
+ abstract listForUser(userId: string): Promise<FederatedIdentity[]>;
349
+ /**
350
+ * Link a provider account to a user. Throws `UserAuthError("ALREADY_EXISTS")`
351
+ * when `(provider, subject)` is already linked — to ANY user — which is the
352
+ * DB-enforced guarantee that one provider account maps to one aooth user.
353
+ */
354
+ abstract link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
355
+ /**
356
+ * Remove a single provider link. Returns `true` when a row was removed,
357
+ * `false` when `(provider, subject)` was not linked. (The "don't strand the
358
+ * user without a usable credential" guard lives in the service layer, not
359
+ * here — this is the raw delete.)
360
+ */
361
+ abstract unlink(provider: string, subject: string): Promise<boolean>;
362
+ /**
363
+ * Stamp `lastLoginAt = now` and merge any DEFINED `profile` fields onto the
364
+ * row. Profile is optional and merged field-by-field, so a provider that
365
+ * omits the email on a repeat login (e.g. Apple after the first auth) never
366
+ * nulls the stored snapshot. No-op when `(provider, subject)` is not linked.
367
+ */
368
+ abstract touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
369
+ /**
370
+ * Remove every identity linked to a user — GDPR hard-delete / "disconnect
371
+ * everything". Returns the number of rows removed. The app-level complement
372
+ * to a DB `onDelete cascade` (which this design deliberately does not use —
373
+ * `userId` is a plain column, not an FK).
374
+ */
375
+ abstract deleteAllForUser(userId: string): Promise<number>;
376
+ }
377
+ //#endregion
378
+ export { TotpConfig as C, UserCredentials as D, UserAuthErrorType as E, UserServiceConfig as O, PolicyCheckResult as S, TrustedDeviceRecord as T, PasswordData as _, pickDefinedProfile as a, PasswordPolicyEvalFn as b, AccountData as c, LockoutConfig as d, LoginResult as f, PasswordConfig as g, MfaMethodInfo as h, NewFederatedIdentity as i, UserStoreUpdate as k, DeepPartial as l, MfaMethod as m, FederatedIdentityStore as n, UserStore as o, MfaData as p, FederatedProfileSnapshot as r, WithCasOptions as s, FederatedIdentity as t, LockStatus as u, PasswordPolicyContext as v, TransferablePolicy as w, PasswordPolicyInstance as x, PasswordPolicyDef as y };
@@ -15,6 +15,8 @@ const defaultMessages = {
15
15
  CAS_EXHAUSTED: "Update conflict — please retry"
16
16
  };
17
17
  var UserAuthError = class extends Error {
18
+ type;
19
+ details;
18
20
  name = "UserAuthError";
19
21
  constructor(type, message, details) {
20
22
  super(message ?? defaultMessages[type]);
@@ -92,8 +94,51 @@ function incrementAtPath(obj, path, amount) {
92
94
  }
93
95
  //#endregion
94
96
  //#region src/store/user-store.ts
97
+ /**
98
+ * Storage seam for user credentials, keyed by the stable surrogate **`id`**
99
+ * (the token subject). Reads come in three flavours:
100
+ *
101
+ * - `findById` — strict, by the surrogate id; the canonical identity read used
102
+ * by authenticated flows that resolve the session subject (`getUserId()`).
103
+ * - `findByHandle` — deterministic LOGIN resolver (`username` then `email`).
104
+ * - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
105
+ * `username`, then `email`).
106
+ *
107
+ * Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
108
+ */
95
109
  var UserStore = class {};
96
110
  //#endregion
111
+ //#region src/store/federated-identity-store.ts
112
+ /**
113
+ * Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
114
+ * partial profile (e.g. Apple omitting the email on a repeat login) never
115
+ * overwrites a stored value with `undefined`. Shared by every
116
+ * {@link FederatedIdentityStore} impl.
117
+ */
118
+ function pickDefinedProfile(src) {
119
+ const out = {};
120
+ if (src.email !== void 0) out.email = src.email;
121
+ if (src.emailVerified !== void 0) out.emailVerified = src.emailVerified;
122
+ if (src.displayName !== void 0) out.displayName = src.displayName;
123
+ if (src.avatarUrl !== void 0) out.avatarUrl = src.avatarUrl;
124
+ return out;
125
+ }
126
+ /**
127
+ * Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
128
+ * lookup key is the composite `(provider, subject)`; a user may own many rows
129
+ * (one per linked provider account), so `userId` reads return a list.
130
+ *
131
+ * In-memory + atscript-db implementations ship alongside; the abstract surface
132
+ * keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
133
+ */
134
+ var FederatedIdentityStore = class {};
135
+ //#endregion
136
+ Object.defineProperty(exports, "FederatedIdentityStore", {
137
+ enumerable: true,
138
+ get: function() {
139
+ return FederatedIdentityStore;
140
+ }
141
+ });
97
142
  Object.defineProperty(exports, "UserAuthError", {
98
143
  enumerable: true,
99
144
  get: function() {
@@ -142,6 +187,12 @@ Object.defineProperty(exports, "maskPhone", {
142
187
  return maskPhone;
143
188
  }
144
189
  });
190
+ Object.defineProperty(exports, "pickDefinedProfile", {
191
+ enumerable: true,
192
+ get: function() {
193
+ return pickDefinedProfile;
194
+ }
195
+ });
145
196
  Object.defineProperty(exports, "setAtPath", {
146
197
  enumerable: true,
147
198
  get: function() {