@aooth/user 0.1.18 → 0.1.20

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-CI7Vgllp.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-I0uyqf2M.cjs";
2
2
 
3
3
  //#region src/atscript-db/federated-identity-store.d.ts
4
4
  /**
@@ -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-CI7Vgllp.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-I0uyqf2M.mjs";
2
2
 
3
3
  //#region src/atscript-db/federated-identity-store.d.ts
4
4
  /**
@@ -19,6 +19,16 @@ interface UserCredentials {
19
19
  * Absent when the user has never opted in.
20
20
  */
21
21
  trustedDevices?: TrustedDeviceRecord[];
22
+ /**
23
+ * Recognition ledger — devices that completed a login. Used purely to
24
+ * suppress the "new sign-in" notification; carries NO security bypass
25
+ * (unlike `trustedDevices`, which skips MFA). Same record shape as trust
26
+ * (`ip` stays unused for recognition). Capped + LRU-evicted by most-recent
27
+ * verification — `expiresAt` is the LRU key, since verification slides it.
28
+ * Managed by `UserService.{issue,add,verify,list}SeenDevice` /
29
+ * `revokeSeenDevices`.
30
+ */
31
+ seenDevices?: TrustedDeviceRecord[];
22
32
  }
23
33
  interface TrustedDeviceRecord {
24
34
  /** `<raw>.<sig>` — what we hand back to the consumer and what they round-trip. */
@@ -55,6 +65,14 @@ interface AccountData {
55
65
  * the invite has been accepted.
56
66
  */
57
67
  pendingInvitation?: boolean;
68
+ /**
69
+ * Correspondence address whose inbox the user PROVED ownership of — written
70
+ * at every inbox-proof moment (invite magic-link click, signup / recovery
71
+ * OTP, email-channel confirm, trusted federated profile). Auth-owned; NOT a
72
+ * login handle (no uniqueness, never used for account resolution). Read
73
+ * through `UserService.getCorrespondenceEmail`.
74
+ */
75
+ verifiedEmail?: string;
58
76
  }
59
77
  interface MfaData {
60
78
  /** Registered MFA methods */
@@ -85,12 +103,20 @@ interface UserServiceConfig {
85
103
  clock?: () => number;
86
104
  /**
87
105
  * Device-trust config. Required (with a non-empty `secret`) when any
88
- * `issueTrustedDevice` / `verifyTrustedDevice` API is called; the methods
89
- * throw clearly when invoked without it.
106
+ * `issueTrustedDevice` / `verifyTrustedDevice` API or any `*SeenDevice`
107
+ * recognition API, which reuses the same secret with a domain-separated
108
+ * payload — is called; the methods throw clearly when invoked without it.
90
109
  */
91
110
  deviceTrust?: {
92
- /** HMAC-SHA256 signing secret for trust-device tokens. */secret: string;
111
+ /** HMAC-SHA256 signing secret for trust-device and seen-device (recognition) tokens. */secret: string;
93
112
  };
113
+ /**
114
+ * Name of the consumer's `@aooth.user.email`-annotated column (resolved at
115
+ * boot by `getAoothUserHandleSpec` in arbac-moost and threaded here as
116
+ * plain config). When set, `getCorrespondenceEmail` prefers that column's
117
+ * value over `account.verifiedEmail` and the confirmed email MFA method.
118
+ */
119
+ emailField?: string;
94
120
  }
95
121
  interface PasswordConfig {
96
122
  /** Pepper string prepended to password before hashing */
@@ -19,6 +19,16 @@ interface UserCredentials {
19
19
  * Absent when the user has never opted in.
20
20
  */
21
21
  trustedDevices?: TrustedDeviceRecord[];
22
+ /**
23
+ * Recognition ledger — devices that completed a login. Used purely to
24
+ * suppress the "new sign-in" notification; carries NO security bypass
25
+ * (unlike `trustedDevices`, which skips MFA). Same record shape as trust
26
+ * (`ip` stays unused for recognition). Capped + LRU-evicted by most-recent
27
+ * verification — `expiresAt` is the LRU key, since verification slides it.
28
+ * Managed by `UserService.{issue,add,verify,list}SeenDevice` /
29
+ * `revokeSeenDevices`.
30
+ */
31
+ seenDevices?: TrustedDeviceRecord[];
22
32
  }
23
33
  interface TrustedDeviceRecord {
24
34
  /** `<raw>.<sig>` — what we hand back to the consumer and what they round-trip. */
@@ -55,6 +65,14 @@ interface AccountData {
55
65
  * the invite has been accepted.
56
66
  */
57
67
  pendingInvitation?: boolean;
68
+ /**
69
+ * Correspondence address whose inbox the user PROVED ownership of — written
70
+ * at every inbox-proof moment (invite magic-link click, signup / recovery
71
+ * OTP, email-channel confirm, trusted federated profile). Auth-owned; NOT a
72
+ * login handle (no uniqueness, never used for account resolution). Read
73
+ * through `UserService.getCorrespondenceEmail`.
74
+ */
75
+ verifiedEmail?: string;
58
76
  }
59
77
  interface MfaData {
60
78
  /** Registered MFA methods */
@@ -85,12 +103,20 @@ interface UserServiceConfig {
85
103
  clock?: () => number;
86
104
  /**
87
105
  * Device-trust config. Required (with a non-empty `secret`) when any
88
- * `issueTrustedDevice` / `verifyTrustedDevice` API is called; the methods
89
- * throw clearly when invoked without it.
106
+ * `issueTrustedDevice` / `verifyTrustedDevice` API or any `*SeenDevice`
107
+ * recognition API, which reuses the same secret with a domain-separated
108
+ * payload — is called; the methods throw clearly when invoked without it.
90
109
  */
91
110
  deviceTrust?: {
92
- /** HMAC-SHA256 signing secret for trust-device tokens. */secret: string;
111
+ /** HMAC-SHA256 signing secret for trust-device and seen-device (recognition) tokens. */secret: string;
93
112
  };
113
+ /**
114
+ * Name of the consumer's `@aooth.user.email`-annotated column (resolved at
115
+ * boot by `getAoothUserHandleSpec` in arbac-moost and threaded here as
116
+ * plain config). When set, `getCorrespondenceEmail` prefers that column's
117
+ * value over `account.verifiedEmail` and the confirmed email MFA method.
118
+ */
119
+ emailField?: string;
94
120
  }
95
121
  interface PasswordConfig {
96
122
  /** Pepper string prepended to password before hashing */
package/dist/index.cjs CHANGED
@@ -293,14 +293,30 @@ function resolveConfig(config) {
293
293
  duration: config?.lockout?.duration ?? 0
294
294
  },
295
295
  clock: config?.clock ?? Date.now,
296
- ...config?.deviceTrust && { deviceTrust: config.deviceTrust }
296
+ ...config?.deviceTrust && { deviceTrust: config.deviceTrust },
297
+ ...config?.emailField && { emailField: config.emailField }
297
298
  };
298
299
  }
299
300
  const DEVICE_TRUST_TOKEN_BYTES = 32;
300
301
  const DEVICE_TRUST_SEPARATOR = ".";
302
+ const SEEN_DEVICES_DEFAULT_CAP = 5;
301
303
  function signDeviceTrust(secret, payload) {
302
304
  return (0, node_crypto.createHmac)("sha256", secret).update(payload).digest("hex");
303
305
  }
306
+ function trustedDevicePayload(userId, raw, ip) {
307
+ return `${userId}|${raw}|${ip ?? ""}`;
308
+ }
309
+ function seenDevicePayload(userId, raw) {
310
+ return `seen|${userId}|${raw}`;
311
+ }
312
+ function parseDeviceTrustToken(token) {
313
+ const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
314
+ if (sepIdx <= 0) return void 0;
315
+ return {
316
+ raw: token.slice(0, sepIdx),
317
+ sig: token.slice(sepIdx + 1)
318
+ };
319
+ }
304
320
  function deviceTrustSafeEqual(a, b) {
305
321
  const ab = Buffer.from(a);
306
322
  const bb = Buffer.from(b);
@@ -451,8 +467,15 @@ var UserService = class {
451
467
  if (!await this.store.update(id, { set: patch })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
452
468
  return this.getUser(id);
453
469
  }
454
- async activateAccount(id) {
455
- if (!await this.store.update(id, { set: { account: { active: true } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
470
+ /**
471
+ * Flip the account active. `opts.verifiedEmail` lets callers that just
472
+ * proved an inbox (invite magic link, signup OTP) record the correspondence
473
+ * address in the same store write — see {@link setVerifiedEmail}.
474
+ */
475
+ async activateAccount(id, opts) {
476
+ const account = { active: true };
477
+ if (opts?.verifiedEmail !== void 0) account.verifiedEmail = opts.verifiedEmail;
478
+ if (!await this.store.update(id, { set: { account } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
456
479
  }
457
480
  async deactivateAccount(id) {
458
481
  if (!await this.store.update(id, { set: { account: { active: false } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
@@ -638,17 +661,7 @@ var UserService = class {
638
661
  * `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
639
662
  */
640
663
  issueTrustedDevice(userId, opts) {
641
- const secret = this.requireDeviceTrustSecret();
642
- const raw = (0, node_crypto.randomBytes)(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
643
- const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
644
- const now = this.config.clock();
645
- return {
646
- token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
647
- ...opts.ip !== void 0 && { ip: opts.ip },
648
- issuedAt: now,
649
- expiresAt: now + opts.ttlMs,
650
- ...opts.name !== void 0 && { name: opts.name }
651
- };
664
+ return this.mintDeviceRecord((raw) => trustedDevicePayload(userId, raw, opts.ip), opts);
652
665
  }
653
666
  /**
654
667
  * Append a trust record to the user's `trustedDevices` list. Read-modify-
@@ -666,11 +679,7 @@ var UserService = class {
666
679
  * within its expiry window and whose bound IP (if any) matches.
667
680
  */
668
681
  async verifyTrustedDevice(userId, token, ip) {
669
- const secret = this.requireDeviceTrustSecret();
670
- const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
671
- if (sepIdx <= 0) return false;
672
- const raw = token.slice(0, sepIdx);
673
- if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
682
+ if (!this.checkDeviceTokenSig(token, (raw) => trustedDevicePayload(userId, raw, ip))) return false;
674
683
  const user = await this.store.findById(userId);
675
684
  if (!user) return false;
676
685
  const list = user.trustedDevices ?? [];
@@ -700,6 +709,167 @@ var UserService = class {
700
709
  if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
701
710
  return secret;
702
711
  }
712
+ /**
713
+ * Shared mint core for trust + recognition records — the HMAC domain is set
714
+ * by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
715
+ */
716
+ mintDeviceRecord(payloadFor, opts) {
717
+ const secret = this.requireDeviceTrustSecret();
718
+ const raw = (0, node_crypto.randomBytes)(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
719
+ const sig = signDeviceTrust(secret, payloadFor(raw));
720
+ const now = this.config.clock();
721
+ return {
722
+ token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
723
+ ...opts.ip !== void 0 && { ip: opts.ip },
724
+ issuedAt: now,
725
+ expiresAt: now + opts.ttlMs,
726
+ ...opts.name !== void 0 && { name: opts.name }
727
+ };
728
+ }
729
+ /**
730
+ * Parse + constant-time HMAC check of a device token. Only the stateless
731
+ * half of verification — the persisted-record (expiry/IP) check stays with
732
+ * the caller. Throws only on a missing secret.
733
+ */
734
+ checkDeviceTokenSig(token, payloadFor) {
735
+ const secret = this.requireDeviceTrustSecret();
736
+ const parsed = parseDeviceTrustToken(token);
737
+ if (!parsed) return false;
738
+ return deviceTrustSafeEqual(parsed.sig, signDeviceTrust(secret, payloadFor(parsed.raw)));
739
+ }
740
+ /**
741
+ * Mint a freshly-signed recognition record (does NOT persist — pair with
742
+ * `addSeenDevice`). No IP binding — recognition is pure noise control.
743
+ * Throws when `deviceTrust.secret` is unset.
744
+ */
745
+ issueSeenDevice(userId, opts) {
746
+ return this.mintDeviceRecord((raw) => seenDevicePayload(userId, raw), opts);
747
+ }
748
+ /**
749
+ * Append a recognition record to the user's `seenDevices` ledger and enforce
750
+ * the cap (default 5): expired records are dropped first, then the
751
+ * least-recently-verified records (smallest `expiresAt` — verification
752
+ * slides it, so it doubles as the LRU key) are evicted until at cap.
753
+ * Read-modify-write under CAS — the array shape is preserved end-to-end so
754
+ * DB adapters with a merge strategy replace the whole array.
755
+ */
756
+ async addSeenDevice(id, record, opts) {
757
+ const cap = opts?.cap ?? 5;
758
+ await this.store.withCas(id, (user) => {
759
+ let next = [...user.seenDevices ?? [], record];
760
+ if (next.length > cap) {
761
+ const now = this.config.clock();
762
+ next = next.filter((r) => r.expiresAt > now);
763
+ if (next.length > cap) {
764
+ next.sort((a, b) => a.expiresAt - b.expiresAt);
765
+ next = next.slice(next.length - cap);
766
+ }
767
+ }
768
+ return { set: { seenDevices: next } };
769
+ });
770
+ }
771
+ /**
772
+ * Returns true when the supplied token signs against the user with the
773
+ * configured secret (recognition domain) AND matches a persisted
774
+ * `seenDevices` record still within its expiry window. On a valid hit with
775
+ * `opts.slideTtlMs` set, the record's `expiresAt` is slid to
776
+ * `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
777
+ * token (only on a missing secret, mirroring `verifyTrustedDevice`).
778
+ */
779
+ async verifySeenDevice(userId, token, opts) {
780
+ if (!this.checkDeviceTokenSig(token, (raw) => seenDevicePayload(userId, raw))) return false;
781
+ const slideTtlMs = opts?.slideTtlMs;
782
+ if (slideTtlMs === void 0) {
783
+ const user = await this.store.findById(userId);
784
+ if (!user) return false;
785
+ const now = this.config.clock();
786
+ return (user.seenDevices ?? []).some((r) => r.token === token && r.expiresAt > now);
787
+ }
788
+ let matched = false;
789
+ try {
790
+ await this.store.withCas(userId, (current) => {
791
+ matched = false;
792
+ const list = current.seenDevices ?? [];
793
+ const now = this.config.clock();
794
+ const idx = list.findIndex((r) => r.token === token && r.expiresAt > now);
795
+ if (idx === -1) return null;
796
+ matched = true;
797
+ const next = [...list];
798
+ next[idx] = {
799
+ ...next[idx],
800
+ expiresAt: now + slideTtlMs
801
+ };
802
+ return { set: { seenDevices: next } };
803
+ });
804
+ } catch (error) {
805
+ if (error instanceof require_federated_identity_store.UserAuthError && error.type === "NOT_FOUND") return false;
806
+ throw error;
807
+ }
808
+ return matched;
809
+ }
810
+ async listSeenDevices(id) {
811
+ return (await this.getUser(id)).seenDevices ?? [];
812
+ }
813
+ /**
814
+ * Clear the whole recognition ledger. Unconditional single write — `update`
815
+ * already no-ops on a missing row, and an empty `seenDevices` is equivalent
816
+ * to an absent one everywhere it is read.
817
+ */
818
+ async revokeSeenDevices(id) {
819
+ await this.store.update(id, { set: { seenDevices: [] } });
820
+ }
821
+ /**
822
+ * True when `deviceTrust.secret` is configured — lets the workflow layer
823
+ * skip device recognition entirely (degrade gracefully) instead of throwing.
824
+ */
825
+ hasDeviceTrustSecret() {
826
+ return !!this.config.deviceTrust?.secret;
827
+ }
828
+ /**
829
+ * Record `account.verifiedEmail` — the correspondence address whose inbox
830
+ * the user just PROVED (invite magic-link click, signup / recovery OTP,
831
+ * email-channel confirm, trusted federated profile). Plain write — a later
832
+ * proof for a different address overwrites the previous one. Throws
833
+ * `UserAuthError("NOT_FOUND")` when no row matches, like the other account
834
+ * mutators.
835
+ */
836
+ async setVerifiedEmail(id, email) {
837
+ if (!await this.store.update(id, { set: { account: { verifiedEmail: email } } })) throw new require_federated_identity_store.UserAuthError("NOT_FOUND");
838
+ }
839
+ /**
840
+ * Resolve the address security notices should go to — a pure accessor over
841
+ * a row the caller already holds (no store round-trip). PROVEN-first chain:
842
+ * 1. `account.verifiedEmail` — the auth-proven capture written by
843
+ * {@link setVerifiedEmail} at inbox-proof moments;
844
+ * 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
845
+ * (pre-capture legacy rows; going forward the confirm step writes
846
+ * `verifiedEmail` too, so this level fades to a fallback);
847
+ * 3. the consumer's email column (`config.emailField`, the
848
+ * `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
849
+ * (an admin-typed address proves nothing about the inbox).
850
+ * Returns `undefined` when none yields a non-empty address.
851
+ *
852
+ * Proven-first is deliberate for SECURITY notices: when an email changes,
853
+ * the notice should reach the previously-proven inbox, not an address
854
+ * nobody has demonstrated control of (mirrors the standard
855
+ * "your-email-was-changed goes to the OLD address" posture). Deployments
856
+ * that want app-canonical-first override this method.
857
+ *
858
+ * OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
859
+ * table, a tenant directory, an external CRM) — the return type admits
860
+ * `Promise` so an override can hit a store; the default stays sync (no
861
+ * Promise allocation). Callers `await` the result.
862
+ */
863
+ getCorrespondenceEmail(user) {
864
+ if (user.account.verifiedEmail) return user.account.verifiedEmail;
865
+ const mfaEmail = user.mfa.methods.find((m) => m.name === "email" && m.confirmed && m.value);
866
+ if (mfaEmail) return mfaEmail.value;
867
+ const emailField = this.config.emailField;
868
+ if (emailField) {
869
+ const value = user[emailField];
870
+ if (typeof value === "string" && value) return value;
871
+ }
872
+ }
703
873
  async applyPasswordChange(id, user, newPassword) {
704
874
  const policyResult = await this.checkPolicies(newPassword, user.password);
705
875
  if (!policyResult.passed) throw new require_federated_identity_store.UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
@@ -1019,6 +1189,7 @@ exports.FederatedIdentityStore = require_federated_identity_store.FederatedIdent
1019
1189
  exports.FederatedIdentityStoreMemory = FederatedIdentityStoreMemory;
1020
1190
  exports.PasswordHasher = PasswordHasher;
1021
1191
  exports.PasswordPolicy = PasswordPolicy;
1192
+ exports.SEEN_DEVICES_DEFAULT_CAP = SEEN_DEVICES_DEFAULT_CAP;
1022
1193
  exports.UserAuthError = require_federated_identity_store.UserAuthError;
1023
1194
  exports.UserService = UserService;
1024
1195
  exports.UserStore = require_federated_identity_store.UserStore;
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-CI7Vgllp.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-I0uyqf2M.cjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -70,7 +70,9 @@ interface ResolvedConfig {
70
70
  deviceTrust?: {
71
71
  secret: string;
72
72
  };
73
+ emailField?: string;
73
74
  }
75
+ declare const SEEN_DEVICES_DEFAULT_CAP = 5;
74
76
  /**
75
77
  * Orchestrates user credentials over a pluggable {@link UserStore}.
76
78
  *
@@ -138,7 +140,14 @@ declare class UserService<T extends object = object> {
138
140
  * Used by the invite workflow's `applyProfile` default fallback.
139
141
  */
140
142
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
141
- activateAccount(id: string): Promise<void>;
143
+ /**
144
+ * Flip the account active. `opts.verifiedEmail` lets callers that just
145
+ * proved an inbox (invite magic link, signup OTP) record the correspondence
146
+ * address in the same store write — see {@link setVerifiedEmail}.
147
+ */
148
+ activateAccount(id: string, opts?: {
149
+ verifiedEmail?: string;
150
+ }): Promise<void>;
142
151
  deactivateAccount(id: string): Promise<void>;
143
152
  lockAccount(id: string, reason: string, duration?: number): Promise<void>;
144
153
  unlockAccount(id: string): Promise<void>;
@@ -213,6 +222,94 @@ declare class UserService<T extends object = object> {
213
222
  revokeTrustedDevice(id: string, token: string): Promise<void>;
214
223
  listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
215
224
  private requireDeviceTrustSecret;
225
+ /**
226
+ * Shared mint core for trust + recognition records — the HMAC domain is set
227
+ * by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
228
+ */
229
+ private mintDeviceRecord;
230
+ /**
231
+ * Parse + constant-time HMAC check of a device token. Only the stateless
232
+ * half of verification — the persisted-record (expiry/IP) check stays with
233
+ * the caller. Throws only on a missing secret.
234
+ */
235
+ private checkDeviceTokenSig;
236
+ /**
237
+ * Mint a freshly-signed recognition record (does NOT persist — pair with
238
+ * `addSeenDevice`). No IP binding — recognition is pure noise control.
239
+ * Throws when `deviceTrust.secret` is unset.
240
+ */
241
+ issueSeenDevice(userId: string, opts: {
242
+ ttlMs: number;
243
+ name?: string;
244
+ }): TrustedDeviceRecord;
245
+ /**
246
+ * Append a recognition record to the user's `seenDevices` ledger and enforce
247
+ * the cap (default 5): expired records are dropped first, then the
248
+ * least-recently-verified records (smallest `expiresAt` — verification
249
+ * slides it, so it doubles as the LRU key) are evicted until at cap.
250
+ * Read-modify-write under CAS — the array shape is preserved end-to-end so
251
+ * DB adapters with a merge strategy replace the whole array.
252
+ */
253
+ addSeenDevice(id: string, record: TrustedDeviceRecord, opts?: {
254
+ cap?: number;
255
+ }): Promise<void>;
256
+ /**
257
+ * Returns true when the supplied token signs against the user with the
258
+ * configured secret (recognition domain) AND matches a persisted
259
+ * `seenDevices` record still within its expiry window. On a valid hit with
260
+ * `opts.slideTtlMs` set, the record's `expiresAt` is slid to
261
+ * `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
262
+ * token (only on a missing secret, mirroring `verifyTrustedDevice`).
263
+ */
264
+ verifySeenDevice(userId: string, token: string, opts?: {
265
+ slideTtlMs?: number;
266
+ }): Promise<boolean>;
267
+ listSeenDevices(id: string): Promise<TrustedDeviceRecord[]>;
268
+ /**
269
+ * Clear the whole recognition ledger. Unconditional single write — `update`
270
+ * already no-ops on a missing row, and an empty `seenDevices` is equivalent
271
+ * to an absent one everywhere it is read.
272
+ */
273
+ revokeSeenDevices(id: string): Promise<void>;
274
+ /**
275
+ * True when `deviceTrust.secret` is configured — lets the workflow layer
276
+ * skip device recognition entirely (degrade gracefully) instead of throwing.
277
+ */
278
+ hasDeviceTrustSecret(): boolean;
279
+ /**
280
+ * Record `account.verifiedEmail` — the correspondence address whose inbox
281
+ * the user just PROVED (invite magic-link click, signup / recovery OTP,
282
+ * email-channel confirm, trusted federated profile). Plain write — a later
283
+ * proof for a different address overwrites the previous one. Throws
284
+ * `UserAuthError("NOT_FOUND")` when no row matches, like the other account
285
+ * mutators.
286
+ */
287
+ setVerifiedEmail(id: string, email: string): Promise<void>;
288
+ /**
289
+ * Resolve the address security notices should go to — a pure accessor over
290
+ * a row the caller already holds (no store round-trip). PROVEN-first chain:
291
+ * 1. `account.verifiedEmail` — the auth-proven capture written by
292
+ * {@link setVerifiedEmail} at inbox-proof moments;
293
+ * 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
294
+ * (pre-capture legacy rows; going forward the confirm step writes
295
+ * `verifiedEmail` too, so this level fades to a fallback);
296
+ * 3. the consumer's email column (`config.emailField`, the
297
+ * `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
298
+ * (an admin-typed address proves nothing about the inbox).
299
+ * Returns `undefined` when none yields a non-empty address.
300
+ *
301
+ * Proven-first is deliberate for SECURITY notices: when an email changes,
302
+ * the notice should reach the previously-proven inbox, not an address
303
+ * nobody has demonstrated control of (mirrors the standard
304
+ * "your-email-was-changed goes to the OLD address" posture). Deployments
305
+ * that want app-canonical-first override this method.
306
+ *
307
+ * OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
308
+ * table, a tenant directory, an external CRM) — the return type admits
309
+ * `Promise` so an override can hit a store; the default stays sync (no
310
+ * Promise allocation). Callers `await` the result.
311
+ */
312
+ getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
216
313
  private applyPasswordChange;
217
314
  private hasConfirmedMfaMethods;
218
315
  /**
@@ -343,4 +440,4 @@ declare function maskPhone(phone: string): string;
343
440
  declare function maskMfaValue(method: MfaMethod): string;
344
441
  declare function setAtPath(obj: object, path: string, value: unknown): void;
345
442
  //#endregion
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 };
443
+ 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, SEEN_DEVICES_DEFAULT_CAP, 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 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";
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-I0uyqf2M.mjs";
2
2
 
3
3
  //#region src/errors.d.ts
4
4
  declare class UserAuthError extends Error {
@@ -70,7 +70,9 @@ interface ResolvedConfig {
70
70
  deviceTrust?: {
71
71
  secret: string;
72
72
  };
73
+ emailField?: string;
73
74
  }
75
+ declare const SEEN_DEVICES_DEFAULT_CAP = 5;
74
76
  /**
75
77
  * Orchestrates user credentials over a pluggable {@link UserStore}.
76
78
  *
@@ -138,7 +140,14 @@ declare class UserService<T extends object = object> {
138
140
  * Used by the invite workflow's `applyProfile` default fallback.
139
141
  */
140
142
  update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>;
141
- activateAccount(id: string): Promise<void>;
143
+ /**
144
+ * Flip the account active. `opts.verifiedEmail` lets callers that just
145
+ * proved an inbox (invite magic link, signup OTP) record the correspondence
146
+ * address in the same store write — see {@link setVerifiedEmail}.
147
+ */
148
+ activateAccount(id: string, opts?: {
149
+ verifiedEmail?: string;
150
+ }): Promise<void>;
142
151
  deactivateAccount(id: string): Promise<void>;
143
152
  lockAccount(id: string, reason: string, duration?: number): Promise<void>;
144
153
  unlockAccount(id: string): Promise<void>;
@@ -213,6 +222,94 @@ declare class UserService<T extends object = object> {
213
222
  revokeTrustedDevice(id: string, token: string): Promise<void>;
214
223
  listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>;
215
224
  private requireDeviceTrustSecret;
225
+ /**
226
+ * Shared mint core for trust + recognition records — the HMAC domain is set
227
+ * by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
228
+ */
229
+ private mintDeviceRecord;
230
+ /**
231
+ * Parse + constant-time HMAC check of a device token. Only the stateless
232
+ * half of verification — the persisted-record (expiry/IP) check stays with
233
+ * the caller. Throws only on a missing secret.
234
+ */
235
+ private checkDeviceTokenSig;
236
+ /**
237
+ * Mint a freshly-signed recognition record (does NOT persist — pair with
238
+ * `addSeenDevice`). No IP binding — recognition is pure noise control.
239
+ * Throws when `deviceTrust.secret` is unset.
240
+ */
241
+ issueSeenDevice(userId: string, opts: {
242
+ ttlMs: number;
243
+ name?: string;
244
+ }): TrustedDeviceRecord;
245
+ /**
246
+ * Append a recognition record to the user's `seenDevices` ledger and enforce
247
+ * the cap (default 5): expired records are dropped first, then the
248
+ * least-recently-verified records (smallest `expiresAt` — verification
249
+ * slides it, so it doubles as the LRU key) are evicted until at cap.
250
+ * Read-modify-write under CAS — the array shape is preserved end-to-end so
251
+ * DB adapters with a merge strategy replace the whole array.
252
+ */
253
+ addSeenDevice(id: string, record: TrustedDeviceRecord, opts?: {
254
+ cap?: number;
255
+ }): Promise<void>;
256
+ /**
257
+ * Returns true when the supplied token signs against the user with the
258
+ * configured secret (recognition domain) AND matches a persisted
259
+ * `seenDevices` record still within its expiry window. On a valid hit with
260
+ * `opts.slideTtlMs` set, the record's `expiresAt` is slid to
261
+ * `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
262
+ * token (only on a missing secret, mirroring `verifyTrustedDevice`).
263
+ */
264
+ verifySeenDevice(userId: string, token: string, opts?: {
265
+ slideTtlMs?: number;
266
+ }): Promise<boolean>;
267
+ listSeenDevices(id: string): Promise<TrustedDeviceRecord[]>;
268
+ /**
269
+ * Clear the whole recognition ledger. Unconditional single write — `update`
270
+ * already no-ops on a missing row, and an empty `seenDevices` is equivalent
271
+ * to an absent one everywhere it is read.
272
+ */
273
+ revokeSeenDevices(id: string): Promise<void>;
274
+ /**
275
+ * True when `deviceTrust.secret` is configured — lets the workflow layer
276
+ * skip device recognition entirely (degrade gracefully) instead of throwing.
277
+ */
278
+ hasDeviceTrustSecret(): boolean;
279
+ /**
280
+ * Record `account.verifiedEmail` — the correspondence address whose inbox
281
+ * the user just PROVED (invite magic-link click, signup / recovery OTP,
282
+ * email-channel confirm, trusted federated profile). Plain write — a later
283
+ * proof for a different address overwrites the previous one. Throws
284
+ * `UserAuthError("NOT_FOUND")` when no row matches, like the other account
285
+ * mutators.
286
+ */
287
+ setVerifiedEmail(id: string, email: string): Promise<void>;
288
+ /**
289
+ * Resolve the address security notices should go to — a pure accessor over
290
+ * a row the caller already holds (no store round-trip). PROVEN-first chain:
291
+ * 1. `account.verifiedEmail` — the auth-proven capture written by
292
+ * {@link setVerifiedEmail} at inbox-proof moments;
293
+ * 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
294
+ * (pre-capture legacy rows; going forward the confirm step writes
295
+ * `verifiedEmail` too, so this level fades to a fallback);
296
+ * 3. the consumer's email column (`config.emailField`, the
297
+ * `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
298
+ * (an admin-typed address proves nothing about the inbox).
299
+ * Returns `undefined` when none yields a non-empty address.
300
+ *
301
+ * Proven-first is deliberate for SECURITY notices: when an email changes,
302
+ * the notice should reach the previously-proven inbox, not an address
303
+ * nobody has demonstrated control of (mirrors the standard
304
+ * "your-email-was-changed goes to the OLD address" posture). Deployments
305
+ * that want app-canonical-first override this method.
306
+ *
307
+ * OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
308
+ * table, a tenant directory, an external CRM) — the return type admits
309
+ * `Promise` so an override can hit a store; the default stays sync (no
310
+ * Promise allocation). Callers `await` the result.
311
+ */
312
+ getCorrespondenceEmail(user: UserCredentials & T): string | undefined | Promise<string | undefined>;
216
313
  private applyPasswordChange;
217
314
  private hasConfirmedMfaMethods;
218
315
  /**
@@ -343,4 +440,4 @@ declare function maskPhone(phone: string): string;
343
440
  declare function maskMfaValue(method: MfaMethod): string;
344
441
  declare function setAtPath(obj: object, path: string, value: unknown): void;
345
442
  //#endregion
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 };
443
+ 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, SEEN_DEVICES_DEFAULT_CAP, 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.mjs CHANGED
@@ -292,14 +292,30 @@ function resolveConfig(config) {
292
292
  duration: config?.lockout?.duration ?? 0
293
293
  },
294
294
  clock: config?.clock ?? Date.now,
295
- ...config?.deviceTrust && { deviceTrust: config.deviceTrust }
295
+ ...config?.deviceTrust && { deviceTrust: config.deviceTrust },
296
+ ...config?.emailField && { emailField: config.emailField }
296
297
  };
297
298
  }
298
299
  const DEVICE_TRUST_TOKEN_BYTES = 32;
299
300
  const DEVICE_TRUST_SEPARATOR = ".";
301
+ const SEEN_DEVICES_DEFAULT_CAP = 5;
300
302
  function signDeviceTrust(secret, payload) {
301
303
  return createHmac("sha256", secret).update(payload).digest("hex");
302
304
  }
305
+ function trustedDevicePayload(userId, raw, ip) {
306
+ return `${userId}|${raw}|${ip ?? ""}`;
307
+ }
308
+ function seenDevicePayload(userId, raw) {
309
+ return `seen|${userId}|${raw}`;
310
+ }
311
+ function parseDeviceTrustToken(token) {
312
+ const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
313
+ if (sepIdx <= 0) return void 0;
314
+ return {
315
+ raw: token.slice(0, sepIdx),
316
+ sig: token.slice(sepIdx + 1)
317
+ };
318
+ }
303
319
  function deviceTrustSafeEqual(a, b) {
304
320
  const ab = Buffer.from(a);
305
321
  const bb = Buffer.from(b);
@@ -450,8 +466,15 @@ var UserService = class {
450
466
  if (!await this.store.update(id, { set: patch })) throw new UserAuthError("NOT_FOUND");
451
467
  return this.getUser(id);
452
468
  }
453
- async activateAccount(id) {
454
- if (!await this.store.update(id, { set: { account: { active: true } } })) throw new UserAuthError("NOT_FOUND");
469
+ /**
470
+ * Flip the account active. `opts.verifiedEmail` lets callers that just
471
+ * proved an inbox (invite magic link, signup OTP) record the correspondence
472
+ * address in the same store write — see {@link setVerifiedEmail}.
473
+ */
474
+ async activateAccount(id, opts) {
475
+ const account = { active: true };
476
+ if (opts?.verifiedEmail !== void 0) account.verifiedEmail = opts.verifiedEmail;
477
+ if (!await this.store.update(id, { set: { account } })) throw new UserAuthError("NOT_FOUND");
455
478
  }
456
479
  async deactivateAccount(id) {
457
480
  if (!await this.store.update(id, { set: { account: { active: false } } })) throw new UserAuthError("NOT_FOUND");
@@ -637,17 +660,7 @@ var UserService = class {
637
660
  * `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
638
661
  */
639
662
  issueTrustedDevice(userId, opts) {
640
- const secret = this.requireDeviceTrustSecret();
641
- const raw = randomBytes(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
642
- const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
643
- const now = this.config.clock();
644
- return {
645
- token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
646
- ...opts.ip !== void 0 && { ip: opts.ip },
647
- issuedAt: now,
648
- expiresAt: now + opts.ttlMs,
649
- ...opts.name !== void 0 && { name: opts.name }
650
- };
663
+ return this.mintDeviceRecord((raw) => trustedDevicePayload(userId, raw, opts.ip), opts);
651
664
  }
652
665
  /**
653
666
  * Append a trust record to the user's `trustedDevices` list. Read-modify-
@@ -665,11 +678,7 @@ var UserService = class {
665
678
  * within its expiry window and whose bound IP (if any) matches.
666
679
  */
667
680
  async verifyTrustedDevice(userId, token, ip) {
668
- const secret = this.requireDeviceTrustSecret();
669
- const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
670
- if (sepIdx <= 0) return false;
671
- const raw = token.slice(0, sepIdx);
672
- if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${userId}|${raw}|${ip ?? ""}`))) return false;
681
+ if (!this.checkDeviceTokenSig(token, (raw) => trustedDevicePayload(userId, raw, ip))) return false;
673
682
  const user = await this.store.findById(userId);
674
683
  if (!user) return false;
675
684
  const list = user.trustedDevices ?? [];
@@ -699,6 +708,167 @@ var UserService = class {
699
708
  if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
700
709
  return secret;
701
710
  }
711
+ /**
712
+ * Shared mint core for trust + recognition records — the HMAC domain is set
713
+ * by `payloadFor` (see `trustedDevicePayload` / `seenDevicePayload`).
714
+ */
715
+ mintDeviceRecord(payloadFor, opts) {
716
+ const secret = this.requireDeviceTrustSecret();
717
+ const raw = randomBytes(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
718
+ const sig = signDeviceTrust(secret, payloadFor(raw));
719
+ const now = this.config.clock();
720
+ return {
721
+ token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
722
+ ...opts.ip !== void 0 && { ip: opts.ip },
723
+ issuedAt: now,
724
+ expiresAt: now + opts.ttlMs,
725
+ ...opts.name !== void 0 && { name: opts.name }
726
+ };
727
+ }
728
+ /**
729
+ * Parse + constant-time HMAC check of a device token. Only the stateless
730
+ * half of verification — the persisted-record (expiry/IP) check stays with
731
+ * the caller. Throws only on a missing secret.
732
+ */
733
+ checkDeviceTokenSig(token, payloadFor) {
734
+ const secret = this.requireDeviceTrustSecret();
735
+ const parsed = parseDeviceTrustToken(token);
736
+ if (!parsed) return false;
737
+ return deviceTrustSafeEqual(parsed.sig, signDeviceTrust(secret, payloadFor(parsed.raw)));
738
+ }
739
+ /**
740
+ * Mint a freshly-signed recognition record (does NOT persist — pair with
741
+ * `addSeenDevice`). No IP binding — recognition is pure noise control.
742
+ * Throws when `deviceTrust.secret` is unset.
743
+ */
744
+ issueSeenDevice(userId, opts) {
745
+ return this.mintDeviceRecord((raw) => seenDevicePayload(userId, raw), opts);
746
+ }
747
+ /**
748
+ * Append a recognition record to the user's `seenDevices` ledger and enforce
749
+ * the cap (default 5): expired records are dropped first, then the
750
+ * least-recently-verified records (smallest `expiresAt` — verification
751
+ * slides it, so it doubles as the LRU key) are evicted until at cap.
752
+ * Read-modify-write under CAS — the array shape is preserved end-to-end so
753
+ * DB adapters with a merge strategy replace the whole array.
754
+ */
755
+ async addSeenDevice(id, record, opts) {
756
+ const cap = opts?.cap ?? 5;
757
+ await this.store.withCas(id, (user) => {
758
+ let next = [...user.seenDevices ?? [], record];
759
+ if (next.length > cap) {
760
+ const now = this.config.clock();
761
+ next = next.filter((r) => r.expiresAt > now);
762
+ if (next.length > cap) {
763
+ next.sort((a, b) => a.expiresAt - b.expiresAt);
764
+ next = next.slice(next.length - cap);
765
+ }
766
+ }
767
+ return { set: { seenDevices: next } };
768
+ });
769
+ }
770
+ /**
771
+ * Returns true when the supplied token signs against the user with the
772
+ * configured secret (recognition domain) AND matches a persisted
773
+ * `seenDevices` record still within its expiry window. On a valid hit with
774
+ * `opts.slideTtlMs` set, the record's `expiresAt` is slid to
775
+ * `clock() + slideTtlMs` under CAS — the LRU bump. Never throws on a bad
776
+ * token (only on a missing secret, mirroring `verifyTrustedDevice`).
777
+ */
778
+ async verifySeenDevice(userId, token, opts) {
779
+ if (!this.checkDeviceTokenSig(token, (raw) => seenDevicePayload(userId, raw))) return false;
780
+ const slideTtlMs = opts?.slideTtlMs;
781
+ if (slideTtlMs === void 0) {
782
+ const user = await this.store.findById(userId);
783
+ if (!user) return false;
784
+ const now = this.config.clock();
785
+ return (user.seenDevices ?? []).some((r) => r.token === token && r.expiresAt > now);
786
+ }
787
+ let matched = false;
788
+ try {
789
+ await this.store.withCas(userId, (current) => {
790
+ matched = false;
791
+ const list = current.seenDevices ?? [];
792
+ const now = this.config.clock();
793
+ const idx = list.findIndex((r) => r.token === token && r.expiresAt > now);
794
+ if (idx === -1) return null;
795
+ matched = true;
796
+ const next = [...list];
797
+ next[idx] = {
798
+ ...next[idx],
799
+ expiresAt: now + slideTtlMs
800
+ };
801
+ return { set: { seenDevices: next } };
802
+ });
803
+ } catch (error) {
804
+ if (error instanceof UserAuthError && error.type === "NOT_FOUND") return false;
805
+ throw error;
806
+ }
807
+ return matched;
808
+ }
809
+ async listSeenDevices(id) {
810
+ return (await this.getUser(id)).seenDevices ?? [];
811
+ }
812
+ /**
813
+ * Clear the whole recognition ledger. Unconditional single write — `update`
814
+ * already no-ops on a missing row, and an empty `seenDevices` is equivalent
815
+ * to an absent one everywhere it is read.
816
+ */
817
+ async revokeSeenDevices(id) {
818
+ await this.store.update(id, { set: { seenDevices: [] } });
819
+ }
820
+ /**
821
+ * True when `deviceTrust.secret` is configured — lets the workflow layer
822
+ * skip device recognition entirely (degrade gracefully) instead of throwing.
823
+ */
824
+ hasDeviceTrustSecret() {
825
+ return !!this.config.deviceTrust?.secret;
826
+ }
827
+ /**
828
+ * Record `account.verifiedEmail` — the correspondence address whose inbox
829
+ * the user just PROVED (invite magic-link click, signup / recovery OTP,
830
+ * email-channel confirm, trusted federated profile). Plain write — a later
831
+ * proof for a different address overwrites the previous one. Throws
832
+ * `UserAuthError("NOT_FOUND")` when no row matches, like the other account
833
+ * mutators.
834
+ */
835
+ async setVerifiedEmail(id, email) {
836
+ if (!await this.store.update(id, { set: { account: { verifiedEmail: email } } })) throw new UserAuthError("NOT_FOUND");
837
+ }
838
+ /**
839
+ * Resolve the address security notices should go to — a pure accessor over
840
+ * a row the caller already holds (no store round-trip). PROVEN-first chain:
841
+ * 1. `account.verifiedEmail` — the auth-proven capture written by
842
+ * {@link setVerifiedEmail} at inbox-proof moments;
843
+ * 2. the first CONFIRMED `email` MFA method's value — also a proven inbox
844
+ * (pre-capture legacy rows; going forward the confirm step writes
845
+ * `verifiedEmail` too, so this level fades to a fallback);
846
+ * 3. the consumer's email column (`config.emailField`, the
847
+ * `@aooth.user.email`-annotated handle) — app-canonical but UNPROVEN
848
+ * (an admin-typed address proves nothing about the inbox).
849
+ * Returns `undefined` when none yields a non-empty address.
850
+ *
851
+ * Proven-first is deliberate for SECURITY notices: when an email changes,
852
+ * the notice should reach the previously-proven inbox, not an address
853
+ * nobody has demonstrated control of (mirrors the standard
854
+ * "your-email-was-changed goes to the OLD address" posture). Deployments
855
+ * that want app-canonical-first override this method.
856
+ *
857
+ * OVERRIDE SEAM: subclasses may source the address from anywhere (a profile
858
+ * table, a tenant directory, an external CRM) — the return type admits
859
+ * `Promise` so an override can hit a store; the default stays sync (no
860
+ * Promise allocation). Callers `await` the result.
861
+ */
862
+ getCorrespondenceEmail(user) {
863
+ if (user.account.verifiedEmail) return user.account.verifiedEmail;
864
+ const mfaEmail = user.mfa.methods.find((m) => m.name === "email" && m.confirmed && m.value);
865
+ if (mfaEmail) return mfaEmail.value;
866
+ const emailField = this.config.emailField;
867
+ if (emailField) {
868
+ const value = user[emailField];
869
+ if (typeof value === "string" && value) return value;
870
+ }
871
+ }
702
872
  async applyPasswordChange(id, user, newPassword) {
703
873
  const policyResult = await this.checkPolicies(newPassword, user.password);
704
874
  if (!policyResult.passed) throw new UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
@@ -1014,4 +1184,4 @@ function verifyMfaCode(submitted, expectedHash) {
1014
1184
  return timingSafeEqual(a, b);
1015
1185
  }
1016
1186
  //#endregion
1017
- export { FederatedIdentityStore, FederatedIdentityStoreMemory, PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
1187
+ export { FederatedIdentityStore, FederatedIdentityStoreMemory, PasswordHasher, PasswordPolicy, SEEN_DEVICES_DEFAULT_CAP, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, pickDefinedProfile, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/user",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "User credential primitives for aoothjs",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -60,14 +60,14 @@
60
60
  "access": "public"
61
61
  },
62
62
  "devDependencies": {
63
- "@atscript/core": "^0.1.75",
64
- "@atscript/db": "^0.1.104",
65
- "@atscript/db-sql-tools": "^0.1.104",
66
- "@atscript/db-sqlite": "^0.1.104",
67
- "@atscript/typescript": "^0.1.75",
63
+ "@atscript/core": "^0.1.76",
64
+ "@atscript/db": "^0.1.105",
65
+ "@atscript/db-sql-tools": "^0.1.105",
66
+ "@atscript/db-sqlite": "^0.1.105",
67
+ "@atscript/typescript": "^0.1.76",
68
68
  "@types/better-sqlite3": "^7.6.13",
69
69
  "better-sqlite3": "^12.6.2",
70
- "unplugin-atscript": "^0.1.75"
70
+ "unplugin-atscript": "^0.1.76"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@atscript/db": ">=0.1.79"
@@ -28,6 +28,8 @@ export interface AoothUserCredentials {
28
28
  failedLoginAttempts: number
29
29
  lastLogin: number.timestamp
30
30
  pendingInvitation?: boolean
31
+ // Correspondence address whose inbox the user proved (invite click, OTP, email confirm); auth-owned, not a login handle
32
+ verifiedEmail?: string
31
33
  }
32
34
 
33
35
  @db.patch.strategy 'merge'
@@ -46,4 +48,14 @@ export interface AoothUserCredentials {
46
48
  expiresAt: number.timestamp
47
49
  name?: string
48
50
  }[]
51
+
52
+ // Recognition ledger — devices that completed a login (suppresses the new-sign-in notification; no security bypass)
53
+ @db.patch.strategy 'merge'
54
+ seenDevices?: {
55
+ token: string
56
+ ip?: string
57
+ issuedAt: number.timestamp
58
+ expiresAt: number.timestamp
59
+ name?: string
60
+ }[]
49
61
  }
@@ -31,6 +31,7 @@ export declare class AoothUserCredentials {
31
31
  failedLoginAttempts: number
32
32
  lastLogin: number /* timestamp */
33
33
  pendingInvitation?: boolean
34
+ verifiedEmail?: string
34
35
  }
35
36
  mfa: {
36
37
  methods: {
@@ -49,6 +50,13 @@ export declare class AoothUserCredentials {
49
50
  expiresAt: number /* timestamp */
50
51
  name?: string
51
52
  }[]
53
+ seenDevices?: {
54
+ token: string
55
+ ip?: string
56
+ issuedAt: number /* timestamp */
57
+ expiresAt: number /* timestamp */
58
+ name?: string
59
+ }[]
52
60
  static __is_atscript_annotated_type: true
53
61
  static type: TAtscriptTypeObject<keyof AoothUserCredentials, AoothUserCredentials>
54
62
  static metadata: TMetadataMap<AtscriptMetadata>