@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/atscript-db.cjs +121 -23
- package/dist/atscript-db.d.cts +81 -12
- package/dist/atscript-db.d.mts +81 -12
- package/dist/atscript-db.mjs +116 -19
- package/dist/{user-store-BPZVAboN.cjs → federated-identity-store-BEEEcoaP.cjs} +52 -0
- package/dist/{user-store-BaBmH13V.mjs → federated-identity-store-CHW1xtMp.mjs} +41 -1
- package/dist/{user-store-62LCSa8q.d.mts → federated-identity-store-CI7Vgllp.d.cts} +143 -24
- package/dist/{user-store-BZsKtBHy.d.cts → federated-identity-store-CI7Vgllp.d.mts} +143 -24
- package/dist/index.cjs +321 -220
- package/dist/index.d.cts +123 -73
- package/dist/index.d.mts +123 -73
- package/dist/index.mjs +289 -190
- package/package.json +23 -9
- package/src/atscript-db/federated-identity.as +44 -0
- package/src/atscript-db/federated-identity.as.d.ts +62 -0
- package/src/atscript-db/user-credentials.as +4 -2
- package/src/atscript-db/user-credentials.as.d.ts +61 -0
|
@@ -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,52 @@ 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 the
|
|
104
|
+
* annotation-resolved handle fields — email, then phone — in order).
|
|
105
|
+
* - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
|
|
106
|
+
* the `findByHandle` chain).
|
|
107
|
+
*
|
|
108
|
+
* Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
|
|
109
|
+
*/
|
|
95
110
|
var UserStore = class {};
|
|
96
111
|
//#endregion
|
|
112
|
+
//#region src/store/federated-identity-store.ts
|
|
113
|
+
/**
|
|
114
|
+
* Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
|
|
115
|
+
* partial profile (e.g. Apple omitting the email on a repeat login) never
|
|
116
|
+
* overwrites a stored value with `undefined`. Shared by every
|
|
117
|
+
* {@link FederatedIdentityStore} impl.
|
|
118
|
+
*/
|
|
119
|
+
function pickDefinedProfile(src) {
|
|
120
|
+
const out = {};
|
|
121
|
+
if (src.email !== void 0) out.email = src.email;
|
|
122
|
+
if (src.emailVerified !== void 0) out.emailVerified = src.emailVerified;
|
|
123
|
+
if (src.displayName !== void 0) out.displayName = src.displayName;
|
|
124
|
+
if (src.avatarUrl !== void 0) out.avatarUrl = src.avatarUrl;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
|
|
129
|
+
* lookup key is the composite `(provider, subject)`; a user may own many rows
|
|
130
|
+
* (one per linked provider account), so `userId` reads return a list.
|
|
131
|
+
*
|
|
132
|
+
* In-memory + atscript-db implementations ship alongside; the abstract surface
|
|
133
|
+
* keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
|
|
134
|
+
*/
|
|
135
|
+
var FederatedIdentityStore = class {};
|
|
136
|
+
//#endregion
|
|
137
|
+
Object.defineProperty(exports, "FederatedIdentityStore", {
|
|
138
|
+
enumerable: true,
|
|
139
|
+
get: function() {
|
|
140
|
+
return FederatedIdentityStore;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
97
143
|
Object.defineProperty(exports, "UserAuthError", {
|
|
98
144
|
enumerable: true,
|
|
99
145
|
get: function() {
|
|
@@ -142,6 +188,12 @@ Object.defineProperty(exports, "maskPhone", {
|
|
|
142
188
|
return maskPhone;
|
|
143
189
|
}
|
|
144
190
|
});
|
|
191
|
+
Object.defineProperty(exports, "pickDefinedProfile", {
|
|
192
|
+
enumerable: true,
|
|
193
|
+
get: function() {
|
|
194
|
+
return pickDefinedProfile;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
145
197
|
Object.defineProperty(exports, "setAtPath", {
|
|
146
198
|
enumerable: true,
|
|
147
199
|
get: function() {
|
|
@@ -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,6 +94,44 @@ 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 the
|
|
104
|
+
* annotation-resolved handle fields — email, then phone — in order).
|
|
105
|
+
* - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
|
|
106
|
+
* the `findByHandle` chain).
|
|
107
|
+
*
|
|
108
|
+
* Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
|
|
109
|
+
*/
|
|
95
110
|
var UserStore = class {};
|
|
96
111
|
//#endregion
|
|
97
|
-
|
|
112
|
+
//#region src/store/federated-identity-store.ts
|
|
113
|
+
/**
|
|
114
|
+
* Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
|
|
115
|
+
* partial profile (e.g. Apple omitting the email on a repeat login) never
|
|
116
|
+
* overwrites a stored value with `undefined`. Shared by every
|
|
117
|
+
* {@link FederatedIdentityStore} impl.
|
|
118
|
+
*/
|
|
119
|
+
function pickDefinedProfile(src) {
|
|
120
|
+
const out = {};
|
|
121
|
+
if (src.email !== void 0) out.email = src.email;
|
|
122
|
+
if (src.emailVerified !== void 0) out.emailVerified = src.emailVerified;
|
|
123
|
+
if (src.displayName !== void 0) out.displayName = src.displayName;
|
|
124
|
+
if (src.avatarUrl !== void 0) out.avatarUrl = src.avatarUrl;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
|
|
129
|
+
* lookup key is the composite `(provider, subject)`; a user may own many rows
|
|
130
|
+
* (one per linked provider account), so `userId` reads return a list.
|
|
131
|
+
*
|
|
132
|
+
* In-memory + atscript-db implementations ship alongside; the abstract surface
|
|
133
|
+
* keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
|
|
134
|
+
*/
|
|
135
|
+
var FederatedIdentityStore = class {};
|
|
136
|
+
//#endregion
|
|
137
|
+
export { generateSecureRandom as a, maskMfaValue as c, UserAuthError as d, deepMerge as i, maskPhone as l, pickDefinedProfile as n, incrementAtPath as o, UserStore as r, maskEmail as s, FederatedIdentityStore as t, setAtPath as u };
|
|
@@ -13,12 +13,6 @@ interface UserCredentials {
|
|
|
13
13
|
password: PasswordData;
|
|
14
14
|
account: AccountData;
|
|
15
15
|
mfa: MfaData;
|
|
16
|
-
/**
|
|
17
|
-
* Hashed backup codes (SHA-256, hex-encoded). Generated via
|
|
18
|
-
* `UserService.generateBackupCodes`. Undefined when the user has not
|
|
19
|
-
* enrolled backup codes; an empty array means all codes were consumed.
|
|
20
|
-
*/
|
|
21
|
-
backupCodes?: string[];
|
|
22
16
|
/**
|
|
23
17
|
* Persisted device-trust records ("remember this device, skip MFA next
|
|
24
18
|
* time"). Managed by `UserService.{issue,add,verify,revoke,list}TrustedDevice`.
|
|
@@ -225,30 +219,155 @@ interface WithCasOptions {
|
|
|
225
219
|
*/
|
|
226
220
|
maxAttempts?: number;
|
|
227
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Storage seam for user credentials, keyed by the stable surrogate **`id`**
|
|
224
|
+
* (the token subject). Reads come in three flavours:
|
|
225
|
+
*
|
|
226
|
+
* - `findById` — strict, by the surrogate id; the canonical identity read used
|
|
227
|
+
* by authenticated flows that resolve the session subject (`getUserId()`).
|
|
228
|
+
* - `findByHandle` — deterministic LOGIN resolver (`username`, then the
|
|
229
|
+
* annotation-resolved handle fields — email, then phone — in order).
|
|
230
|
+
* - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
|
|
231
|
+
* the `findByHandle` chain).
|
|
232
|
+
*
|
|
233
|
+
* Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
|
|
234
|
+
*/
|
|
228
235
|
declare abstract class UserStore<T extends object = object> {
|
|
229
|
-
|
|
230
|
-
abstract
|
|
236
|
+
/** True when a user with this login handle (`username`) exists. */
|
|
237
|
+
abstract exists(handle: string): Promise<boolean>;
|
|
238
|
+
/**
|
|
239
|
+
* Strict read by the stable surrogate `id` — the token subject. Authenticated
|
|
240
|
+
* flows resolve the session subject (`useAuth().getUserId()`) through this.
|
|
241
|
+
*/
|
|
242
|
+
abstract findById(id: string): Promise<(UserCredentials & T) | null>;
|
|
243
|
+
/**
|
|
244
|
+
* Deterministic LOGIN resolver: matches `username` exactly, then each
|
|
245
|
+
* annotation-resolved handle field (email, then phone) exactly, in that
|
|
246
|
+
* order. Intentionally NOT a permissive `$or` — handle values are all
|
|
247
|
+
* strings, so a permissive match could silently resolve a value that is one
|
|
248
|
+
* user's username and another's email to an arbitrary account. Each handle
|
|
249
|
+
* field is unique-when-present (the `@aooth.user.*` boot contract), so a
|
|
250
|
+
* present value resolves to at most one row.
|
|
251
|
+
*/
|
|
252
|
+
abstract findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
|
|
253
|
+
/**
|
|
254
|
+
* Permissive lookup for internal / admin / recovery callers: `id`, then the
|
|
255
|
+
* `findByHandle` chain (`username`, then the resolved handle fields). NOT for
|
|
256
|
+
* the login path — use `findByHandle` there.
|
|
257
|
+
*/
|
|
258
|
+
abstract findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
|
|
231
259
|
abstract create(data: UserCredentials & T): Promise<void>;
|
|
232
|
-
|
|
260
|
+
/** Apply a patch to the row identified by the stable `id`. */
|
|
261
|
+
abstract update(id: string, update: UserStoreUpdate): Promise<boolean>;
|
|
233
262
|
/**
|
|
234
|
-
* Hard-delete the row
|
|
235
|
-
* the
|
|
236
|
-
* by the invite workflow's `auth/invite/cancel` step).
|
|
263
|
+
* Hard-delete the row by `id`. Returns `true` when a row was removed, `false`
|
|
264
|
+
* when the id was not found.
|
|
237
265
|
*/
|
|
238
|
-
abstract delete(
|
|
266
|
+
abstract delete(id: string): Promise<boolean>;
|
|
239
267
|
/**
|
|
240
|
-
* Run a read-modify-write cycle under optimistic concurrency
|
|
241
|
-
*
|
|
242
|
-
* patch under CAS (`expectedVersion = current.version`). On CAS miss
|
|
243
|
-
* cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null`
|
|
244
|
-
* exit early without writing
|
|
245
|
-
* do" paths (e.g. the backup code was already consumed by the winner).
|
|
268
|
+
* Run a read-modify-write cycle under optimistic concurrency, keyed by `id`.
|
|
269
|
+
* Each attempt re-reads (via `findById`), calls `mutator`, and applies the
|
|
270
|
+
* returned patch under CAS (`expectedVersion = current.version`). On CAS miss
|
|
271
|
+
* the cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null`
|
|
272
|
+
* to exit early without writing (race-loser "nothing left to do").
|
|
246
273
|
*
|
|
247
|
-
* Throws `UserAuthError("NOT_FOUND")` when no row matches `
|
|
248
|
-
* `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
|
|
249
|
-
*
|
|
274
|
+
* Throws `UserAuthError("NOT_FOUND")` when no row matches `id`, or
|
|
275
|
+
* `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors thrown
|
|
276
|
+
* from inside `mutator` propagate immediately without retry.
|
|
277
|
+
*/
|
|
278
|
+
abstract withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/store/federated-identity-store.d.ts
|
|
282
|
+
/**
|
|
283
|
+
* Display fields snapshotted from an IdP profile onto a federated-identity row.
|
|
284
|
+
* Refreshed on each login via {@link FederatedIdentityStore.touchLogin}; never
|
|
285
|
+
* a join key (the stable join is `(provider, subject)`). Phase-2's
|
|
286
|
+
* `NormalizedProfile` (in `@aooth/idp`) is a structural superset of this — it
|
|
287
|
+
* is declared HERE rather than imported so `@aooth/user` keeps no dependency on
|
|
288
|
+
* the layer above it.
|
|
289
|
+
*/
|
|
290
|
+
interface FederatedProfileSnapshot {
|
|
291
|
+
email?: string;
|
|
292
|
+
emailVerified?: boolean;
|
|
293
|
+
displayName?: string;
|
|
294
|
+
avatarUrl?: string;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
|
|
298
|
+
* partial profile (e.g. Apple omitting the email on a repeat login) never
|
|
299
|
+
* overwrites a stored value with `undefined`. Shared by every
|
|
300
|
+
* {@link FederatedIdentityStore} impl.
|
|
301
|
+
*/
|
|
302
|
+
declare function pickDefinedProfile(src: FederatedProfileSnapshot): FederatedProfileSnapshot;
|
|
303
|
+
/**
|
|
304
|
+
* A persisted federated-identity row: one external-provider account
|
|
305
|
+
* (`provider` + the IdP's stable `subject`) linked to exactly one aooth user
|
|
306
|
+
* (`userId` = the user's surrogate `id`). Mirrors the shipped
|
|
307
|
+
* `AoothFederatedIdentity` `.as` model by construction.
|
|
308
|
+
*/
|
|
309
|
+
interface FederatedIdentity extends FederatedProfileSnapshot {
|
|
310
|
+
/** Surrogate PK. Server-assigned (`@db.default.uuid` / `randomUUID`). */
|
|
311
|
+
id: string;
|
|
312
|
+
provider: string;
|
|
313
|
+
subject: string;
|
|
314
|
+
/** Owner — the user's stable surrogate `id`. */
|
|
315
|
+
userId: string;
|
|
316
|
+
/** When the link was first created. */
|
|
317
|
+
linkedAt: number;
|
|
318
|
+
/** Last federated login through this identity; absent until first `touchLogin`. */
|
|
319
|
+
lastLoginAt?: number;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Input to {@link FederatedIdentityStore.link} — the identity keys + owner plus
|
|
323
|
+
* an optional first-login profile snapshot. `id` and `linkedAt` are assigned by
|
|
324
|
+
* the store.
|
|
325
|
+
*/
|
|
326
|
+
interface NewFederatedIdentity extends FederatedProfileSnapshot {
|
|
327
|
+
provider: string;
|
|
328
|
+
subject: string;
|
|
329
|
+
userId: string;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
|
|
333
|
+
* lookup key is the composite `(provider, subject)`; a user may own many rows
|
|
334
|
+
* (one per linked provider account), so `userId` reads return a list.
|
|
335
|
+
*
|
|
336
|
+
* In-memory + atscript-db implementations ship alongside; the abstract surface
|
|
337
|
+
* keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
|
|
338
|
+
*/
|
|
339
|
+
declare abstract class FederatedIdentityStore {
|
|
340
|
+
/** Resolve a provider account to its linked row, or `null`. The federated-login hot path. */
|
|
341
|
+
abstract find(provider: string, subject: string): Promise<FederatedIdentity | null>;
|
|
342
|
+
/** All identities linked to a user — the "connected accounts" view. */
|
|
343
|
+
abstract listForUser(userId: string): Promise<FederatedIdentity[]>;
|
|
344
|
+
/**
|
|
345
|
+
* Link a provider account to a user. Throws `UserAuthError("ALREADY_EXISTS")`
|
|
346
|
+
* when `(provider, subject)` is already linked — to ANY user — which is the
|
|
347
|
+
* DB-enforced guarantee that one provider account maps to one aooth user.
|
|
348
|
+
*/
|
|
349
|
+
abstract link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
|
|
350
|
+
/**
|
|
351
|
+
* Remove a single provider link. Returns `true` when a row was removed,
|
|
352
|
+
* `false` when `(provider, subject)` was not linked. (The "don't strand the
|
|
353
|
+
* user without a usable credential" guard lives in the service layer, not
|
|
354
|
+
* here — this is the raw delete.)
|
|
355
|
+
*/
|
|
356
|
+
abstract unlink(provider: string, subject: string): Promise<boolean>;
|
|
357
|
+
/**
|
|
358
|
+
* Stamp `lastLoginAt = now` and merge any DEFINED `profile` fields onto the
|
|
359
|
+
* row. Profile is optional and merged field-by-field, so a provider that
|
|
360
|
+
* omits the email on a repeat login (e.g. Apple after the first auth) never
|
|
361
|
+
* nulls the stored snapshot. No-op when `(provider, subject)` is not linked.
|
|
362
|
+
*/
|
|
363
|
+
abstract touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
|
|
364
|
+
/**
|
|
365
|
+
* Remove every identity linked to a user — GDPR hard-delete / "disconnect
|
|
366
|
+
* everything". Returns the number of rows removed. The app-level complement
|
|
367
|
+
* to a DB `onDelete cascade` (which this design deliberately does not use —
|
|
368
|
+
* `userId` is a plain column, not an FK).
|
|
250
369
|
*/
|
|
251
|
-
abstract
|
|
370
|
+
abstract deleteAllForUser(userId: string): Promise<number>;
|
|
252
371
|
}
|
|
253
372
|
//#endregion
|
|
254
|
-
export {
|
|
373
|
+
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 };
|
|
@@ -13,12 +13,6 @@ interface UserCredentials {
|
|
|
13
13
|
password: PasswordData;
|
|
14
14
|
account: AccountData;
|
|
15
15
|
mfa: MfaData;
|
|
16
|
-
/**
|
|
17
|
-
* Hashed backup codes (SHA-256, hex-encoded). Generated via
|
|
18
|
-
* `UserService.generateBackupCodes`. Undefined when the user has not
|
|
19
|
-
* enrolled backup codes; an empty array means all codes were consumed.
|
|
20
|
-
*/
|
|
21
|
-
backupCodes?: string[];
|
|
22
16
|
/**
|
|
23
17
|
* Persisted device-trust records ("remember this device, skip MFA next
|
|
24
18
|
* time"). Managed by `UserService.{issue,add,verify,revoke,list}TrustedDevice`.
|
|
@@ -225,30 +219,155 @@ interface WithCasOptions {
|
|
|
225
219
|
*/
|
|
226
220
|
maxAttempts?: number;
|
|
227
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Storage seam for user credentials, keyed by the stable surrogate **`id`**
|
|
224
|
+
* (the token subject). Reads come in three flavours:
|
|
225
|
+
*
|
|
226
|
+
* - `findById` — strict, by the surrogate id; the canonical identity read used
|
|
227
|
+
* by authenticated flows that resolve the session subject (`getUserId()`).
|
|
228
|
+
* - `findByHandle` — deterministic LOGIN resolver (`username`, then the
|
|
229
|
+
* annotation-resolved handle fields — email, then phone — in order).
|
|
230
|
+
* - `findByIdentifier` — permissive internal/admin/recovery lookup (`id`, then
|
|
231
|
+
* the `findByHandle` chain).
|
|
232
|
+
*
|
|
233
|
+
* Writes (`update`/`delete`/`withCas`) all key on the surrogate `id`.
|
|
234
|
+
*/
|
|
228
235
|
declare abstract class UserStore<T extends object = object> {
|
|
229
|
-
|
|
230
|
-
abstract
|
|
236
|
+
/** True when a user with this login handle (`username`) exists. */
|
|
237
|
+
abstract exists(handle: string): Promise<boolean>;
|
|
238
|
+
/**
|
|
239
|
+
* Strict read by the stable surrogate `id` — the token subject. Authenticated
|
|
240
|
+
* flows resolve the session subject (`useAuth().getUserId()`) through this.
|
|
241
|
+
*/
|
|
242
|
+
abstract findById(id: string): Promise<(UserCredentials & T) | null>;
|
|
243
|
+
/**
|
|
244
|
+
* Deterministic LOGIN resolver: matches `username` exactly, then each
|
|
245
|
+
* annotation-resolved handle field (email, then phone) exactly, in that
|
|
246
|
+
* order. Intentionally NOT a permissive `$or` — handle values are all
|
|
247
|
+
* strings, so a permissive match could silently resolve a value that is one
|
|
248
|
+
* user's username and another's email to an arbitrary account. Each handle
|
|
249
|
+
* field is unique-when-present (the `@aooth.user.*` boot contract), so a
|
|
250
|
+
* present value resolves to at most one row.
|
|
251
|
+
*/
|
|
252
|
+
abstract findByHandle(handle: string): Promise<(UserCredentials & T) | null>;
|
|
253
|
+
/**
|
|
254
|
+
* Permissive lookup for internal / admin / recovery callers: `id`, then the
|
|
255
|
+
* `findByHandle` chain (`username`, then the resolved handle fields). NOT for
|
|
256
|
+
* the login path — use `findByHandle` there.
|
|
257
|
+
*/
|
|
258
|
+
abstract findByIdentifier(value: string): Promise<(UserCredentials & T) | null>;
|
|
231
259
|
abstract create(data: UserCredentials & T): Promise<void>;
|
|
232
|
-
|
|
260
|
+
/** Apply a patch to the row identified by the stable `id`. */
|
|
261
|
+
abstract update(id: string, update: UserStoreUpdate): Promise<boolean>;
|
|
233
262
|
/**
|
|
234
|
-
* Hard-delete the row
|
|
235
|
-
* the
|
|
236
|
-
* by the invite workflow's `auth/invite/cancel` step).
|
|
263
|
+
* Hard-delete the row by `id`. Returns `true` when a row was removed, `false`
|
|
264
|
+
* when the id was not found.
|
|
237
265
|
*/
|
|
238
|
-
abstract delete(
|
|
266
|
+
abstract delete(id: string): Promise<boolean>;
|
|
239
267
|
/**
|
|
240
|
-
* Run a read-modify-write cycle under optimistic concurrency
|
|
241
|
-
*
|
|
242
|
-
* patch under CAS (`expectedVersion = current.version`). On CAS miss
|
|
243
|
-
* cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null`
|
|
244
|
-
* exit early without writing
|
|
245
|
-
* do" paths (e.g. the backup code was already consumed by the winner).
|
|
268
|
+
* Run a read-modify-write cycle under optimistic concurrency, keyed by `id`.
|
|
269
|
+
* Each attempt re-reads (via `findById`), calls `mutator`, and applies the
|
|
270
|
+
* returned patch under CAS (`expectedVersion = current.version`). On CAS miss
|
|
271
|
+
* the cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null`
|
|
272
|
+
* to exit early without writing (race-loser "nothing left to do").
|
|
246
273
|
*
|
|
247
|
-
* Throws `UserAuthError("NOT_FOUND")` when no row matches `
|
|
248
|
-
* `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
|
|
249
|
-
*
|
|
274
|
+
* Throws `UserAuthError("NOT_FOUND")` when no row matches `id`, or
|
|
275
|
+
* `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors thrown
|
|
276
|
+
* from inside `mutator` propagate immediately without retry.
|
|
277
|
+
*/
|
|
278
|
+
abstract withCas(id: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/store/federated-identity-store.d.ts
|
|
282
|
+
/**
|
|
283
|
+
* Display fields snapshotted from an IdP profile onto a federated-identity row.
|
|
284
|
+
* Refreshed on each login via {@link FederatedIdentityStore.touchLogin}; never
|
|
285
|
+
* a join key (the stable join is `(provider, subject)`). Phase-2's
|
|
286
|
+
* `NormalizedProfile` (in `@aooth/idp`) is a structural superset of this — it
|
|
287
|
+
* is declared HERE rather than imported so `@aooth/user` keeps no dependency on
|
|
288
|
+
* the layer above it.
|
|
289
|
+
*/
|
|
290
|
+
interface FederatedProfileSnapshot {
|
|
291
|
+
email?: string;
|
|
292
|
+
emailVerified?: boolean;
|
|
293
|
+
displayName?: string;
|
|
294
|
+
avatarUrl?: string;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Copy only the DEFINED display fields — so a `touchLogin` / `link` with a
|
|
298
|
+
* partial profile (e.g. Apple omitting the email on a repeat login) never
|
|
299
|
+
* overwrites a stored value with `undefined`. Shared by every
|
|
300
|
+
* {@link FederatedIdentityStore} impl.
|
|
301
|
+
*/
|
|
302
|
+
declare function pickDefinedProfile(src: FederatedProfileSnapshot): FederatedProfileSnapshot;
|
|
303
|
+
/**
|
|
304
|
+
* A persisted federated-identity row: one external-provider account
|
|
305
|
+
* (`provider` + the IdP's stable `subject`) linked to exactly one aooth user
|
|
306
|
+
* (`userId` = the user's surrogate `id`). Mirrors the shipped
|
|
307
|
+
* `AoothFederatedIdentity` `.as` model by construction.
|
|
308
|
+
*/
|
|
309
|
+
interface FederatedIdentity extends FederatedProfileSnapshot {
|
|
310
|
+
/** Surrogate PK. Server-assigned (`@db.default.uuid` / `randomUUID`). */
|
|
311
|
+
id: string;
|
|
312
|
+
provider: string;
|
|
313
|
+
subject: string;
|
|
314
|
+
/** Owner — the user's stable surrogate `id`. */
|
|
315
|
+
userId: string;
|
|
316
|
+
/** When the link was first created. */
|
|
317
|
+
linkedAt: number;
|
|
318
|
+
/** Last federated login through this identity; absent until first `touchLogin`. */
|
|
319
|
+
lastLoginAt?: number;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Input to {@link FederatedIdentityStore.link} — the identity keys + owner plus
|
|
323
|
+
* an optional first-login profile snapshot. `id` and `linkedAt` are assigned by
|
|
324
|
+
* the store.
|
|
325
|
+
*/
|
|
326
|
+
interface NewFederatedIdentity extends FederatedProfileSnapshot {
|
|
327
|
+
provider: string;
|
|
328
|
+
subject: string;
|
|
329
|
+
userId: string;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Storage seam for the account-linking table (RFC IDP.md §3.3). The stable
|
|
333
|
+
* lookup key is the composite `(provider, subject)`; a user may own many rows
|
|
334
|
+
* (one per linked provider account), so `userId` reads return a list.
|
|
335
|
+
*
|
|
336
|
+
* In-memory + atscript-db implementations ship alongside; the abstract surface
|
|
337
|
+
* keeps the federated-login core (`@aooth/idp`, phase 2) storage-agnostic.
|
|
338
|
+
*/
|
|
339
|
+
declare abstract class FederatedIdentityStore {
|
|
340
|
+
/** Resolve a provider account to its linked row, or `null`. The federated-login hot path. */
|
|
341
|
+
abstract find(provider: string, subject: string): Promise<FederatedIdentity | null>;
|
|
342
|
+
/** All identities linked to a user — the "connected accounts" view. */
|
|
343
|
+
abstract listForUser(userId: string): Promise<FederatedIdentity[]>;
|
|
344
|
+
/**
|
|
345
|
+
* Link a provider account to a user. Throws `UserAuthError("ALREADY_EXISTS")`
|
|
346
|
+
* when `(provider, subject)` is already linked — to ANY user — which is the
|
|
347
|
+
* DB-enforced guarantee that one provider account maps to one aooth user.
|
|
348
|
+
*/
|
|
349
|
+
abstract link(rec: NewFederatedIdentity): Promise<FederatedIdentity>;
|
|
350
|
+
/**
|
|
351
|
+
* Remove a single provider link. Returns `true` when a row was removed,
|
|
352
|
+
* `false` when `(provider, subject)` was not linked. (The "don't strand the
|
|
353
|
+
* user without a usable credential" guard lives in the service layer, not
|
|
354
|
+
* here — this is the raw delete.)
|
|
355
|
+
*/
|
|
356
|
+
abstract unlink(provider: string, subject: string): Promise<boolean>;
|
|
357
|
+
/**
|
|
358
|
+
* Stamp `lastLoginAt = now` and merge any DEFINED `profile` fields onto the
|
|
359
|
+
* row. Profile is optional and merged field-by-field, so a provider that
|
|
360
|
+
* omits the email on a repeat login (e.g. Apple after the first auth) never
|
|
361
|
+
* nulls the stored snapshot. No-op when `(provider, subject)` is not linked.
|
|
362
|
+
*/
|
|
363
|
+
abstract touchLogin(provider: string, subject: string, profile?: FederatedProfileSnapshot): Promise<void>;
|
|
364
|
+
/**
|
|
365
|
+
* Remove every identity linked to a user — GDPR hard-delete / "disconnect
|
|
366
|
+
* everything". Returns the number of rows removed. The app-level complement
|
|
367
|
+
* to a DB `onDelete cascade` (which this design deliberately does not use —
|
|
368
|
+
* `userId` is a plain column, not an FK).
|
|
250
369
|
*/
|
|
251
|
-
abstract
|
|
370
|
+
abstract deleteAllForUser(userId: string): Promise<number>;
|
|
252
371
|
}
|
|
253
372
|
//#endregion
|
|
254
|
-
export {
|
|
373
|
+
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 };
|