@astram/guard 0.2.0 → 0.3.0

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.
Files changed (170) hide show
  1. package/dist/auth/account.controller.d.ts +245 -0
  2. package/dist/auth/account.controller.d.ts.map +1 -0
  3. package/dist/auth/account.controller.js +673 -0
  4. package/dist/auth/account.controller.js.map +1 -0
  5. package/dist/auth/auth.controller.d.ts +219 -5
  6. package/dist/auth/auth.controller.d.ts.map +1 -1
  7. package/dist/auth/auth.controller.js +790 -12
  8. package/dist/auth/auth.controller.js.map +1 -0
  9. package/dist/auth/auth.module.d.ts +100 -2
  10. package/dist/auth/auth.module.d.ts.map +1 -1
  11. package/dist/auth/auth.module.js +299 -7
  12. package/dist/auth/auth.module.js.map +1 -0
  13. package/dist/auth/connections.js.map +1 -0
  14. package/dist/auth/dto.d.ts +16 -6
  15. package/dist/auth/dto.d.ts.map +1 -1
  16. package/dist/auth/dto.js.map +1 -0
  17. package/dist/auth/pending-mfa-store.js.map +1 -0
  18. package/dist/auth/persistence.module.js.map +1 -0
  19. package/dist/auth/ports/audit-sink.d.ts +47 -0
  20. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  21. package/dist/auth/ports/audit-sink.js +20 -0
  22. package/dist/auth/ports/audit-sink.js.map +1 -0
  23. package/dist/auth/ports/credential-writer.d.ts +23 -0
  24. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  25. package/dist/auth/ports/credential-writer.js +2 -0
  26. package/dist/auth/ports/credential-writer.js.map +1 -0
  27. package/dist/auth/ports/email-sender.d.ts +14 -0
  28. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  29. package/dist/auth/ports/email-sender.js +2 -0
  30. package/dist/auth/ports/email-sender.js.map +1 -0
  31. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  32. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  33. package/dist/auth/ports/mfa-device-store.js +13 -0
  34. package/dist/auth/ports/mfa-device-store.js.map +1 -0
  35. package/dist/auth/ports/profile-writer.d.ts +12 -0
  36. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  37. package/dist/auth/ports/profile-writer.js +2 -0
  38. package/dist/auth/ports/profile-writer.js.map +1 -0
  39. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  40. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  41. package/dist/auth/ports/recovery-code-store.js +2 -0
  42. package/dist/auth/ports/recovery-code-store.js.map +1 -0
  43. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  44. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  45. package/dist/auth/ports/reset-token-store.js +2 -0
  46. package/dist/auth/ports/reset-token-store.js.map +1 -0
  47. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  48. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  49. package/dist/auth/ports/session-facts-projector.js +30 -0
  50. package/dist/auth/ports/session-facts-projector.js.map +1 -0
  51. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  52. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  53. package/dist/auth/ports/session-sweeper.js +2 -0
  54. package/dist/auth/ports/session-sweeper.js.map +1 -0
  55. package/dist/auth/profile-resolver.d.ts +73 -0
  56. package/dist/auth/profile-resolver.d.ts.map +1 -0
  57. package/dist/auth/profile-resolver.js +28 -0
  58. package/dist/auth/profile-resolver.js.map +1 -0
  59. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  60. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  61. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  62. package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
  63. package/dist/auth/tokens.d.ts +174 -0
  64. package/dist/auth/tokens.d.ts.map +1 -1
  65. package/dist/auth/tokens.js +190 -1
  66. package/dist/auth/tokens.js.map +1 -0
  67. package/dist/auth/totp-enrollment.d.ts +42 -0
  68. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  69. package/dist/auth/totp-enrollment.js +96 -0
  70. package/dist/auth/totp-enrollment.js.map +1 -0
  71. package/dist/authentication/argon2id-password-hasher.js.map +1 -0
  72. package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
  73. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  74. package/dist/authentication/mariadb-credential-resolver.js +42 -28
  75. package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
  76. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  77. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  78. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  79. package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
  80. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  81. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  82. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  83. package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
  84. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  85. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  86. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  87. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
  88. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  89. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  90. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  91. package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
  92. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  93. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  94. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  95. package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
  96. package/dist/authz/record-share-cascade.d.ts +52 -0
  97. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  98. package/dist/authz/record-share-cascade.js +49 -0
  99. package/dist/authz/record-share-cascade.js.map +1 -0
  100. package/dist/authz/record-share-writer.d.ts +175 -0
  101. package/dist/authz/record-share-writer.d.ts.map +1 -0
  102. package/dist/authz/record-share-writer.js +225 -0
  103. package/dist/authz/record-share-writer.js.map +1 -0
  104. package/dist/authz/wire-record-authorization.d.ts +66 -0
  105. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  106. package/dist/authz/wire-record-authorization.js +34 -0
  107. package/dist/authz/wire-record-authorization.js.map +1 -0
  108. package/dist/boot/boot-sequence.js.map +1 -0
  109. package/dist/boot/boot.module.js.map +1 -0
  110. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  111. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  112. package/dist/completeness/catalog-reconciler.js +74 -0
  113. package/dist/completeness/catalog-reconciler.js.map +1 -0
  114. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  115. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  116. package/dist/completeness/operation-inventory-scanner.js +79 -0
  117. package/dist/completeness/operation-inventory-scanner.js.map +1 -0
  118. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  119. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  120. package/dist/completeness/ports/catalog-store.js +2 -0
  121. package/dist/completeness/ports/catalog-store.js.map +1 -0
  122. package/dist/config/app-config.js.map +1 -0
  123. package/dist/config/config.module.js.map +1 -0
  124. package/dist/enforcement/classification.decorator.d.ts +33 -14
  125. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  126. package/dist/enforcement/classification.decorator.js +34 -14
  127. package/dist/enforcement/classification.decorator.js.map +1 -0
  128. package/dist/enforcement/core-seam.js.map +1 -0
  129. package/dist/enforcement/enforcement.module.js.map +1 -0
  130. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  131. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  132. package/dist/enforcement/global-enforcement.guard.js +108 -25
  133. package/dist/enforcement/global-enforcement.guard.js.map +1 -0
  134. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  135. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  136. package/dist/enforcement/reenroll-allowlist.js +77 -0
  137. package/dist/enforcement/reenroll-allowlist.js.map +1 -0
  138. package/dist/index.d.ts +35 -3
  139. package/dist/index.d.ts.map +1 -1
  140. package/dist/index.js +159 -7
  141. package/dist/index.js.map +1 -0
  142. package/dist/module/shield-tokens.d.ts +15 -0
  143. package/dist/module/shield-tokens.d.ts.map +1 -0
  144. package/dist/module/shield-tokens.js +17 -0
  145. package/dist/module/shield-tokens.js.map +1 -0
  146. package/dist/module/shield.module.d.ts +153 -9
  147. package/dist/module/shield.module.d.ts.map +1 -1
  148. package/dist/module/shield.module.js +181 -24
  149. package/dist/module/shield.module.js.map +1 -0
  150. package/dist/request-auth/request-principal.d.ts +42 -0
  151. package/dist/request-auth/request-principal.d.ts.map +1 -0
  152. package/dist/request-auth/request-principal.js +87 -0
  153. package/dist/request-auth/request-principal.js.map +1 -0
  154. package/dist/request-auth/session-facts-als.d.ts +65 -0
  155. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  156. package/dist/request-auth/session-facts-als.js +57 -0
  157. package/dist/request-auth/session-facts-als.js.map +1 -0
  158. package/dist/request-auth/session-token-binder.d.ts +130 -0
  159. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  160. package/dist/request-auth/session-token-binder.js +277 -0
  161. package/dist/request-auth/session-token-binder.js.map +1 -0
  162. package/dist/request-auth/tokens.d.ts +14 -0
  163. package/dist/request-auth/tokens.d.ts.map +1 -0
  164. package/dist/request-auth/tokens.js +16 -0
  165. package/dist/request-auth/tokens.js.map +1 -0
  166. package/dist/session/crypto-adapters.js.map +1 -0
  167. package/dist/session/index.js.map +1 -0
  168. package/dist/session/mariadb-session-record-store.js.map +1 -0
  169. package/dist/session/redis-hot-session-store.js.map +1 -0
  170. package/package.json +2 -2
@@ -0,0 +1,673 @@
1
+ "use strict";
2
+ /**
3
+ * The framework-owned **`/account/*` self-scoped HTTP controller** — the
4
+ * authenticated user acting on THEIR OWN credential/profile (spec §2). Unlike the
5
+ * `/auth/*` routes (which run BEFORE a session exists), these routes authenticate
6
+ * the CALLER first — they resolve the bearer access token to the principal's claims
7
+ * and operate ONLY on `claims.id`. There is NO `decide()`/object-type authorization
8
+ * here: "change my own password" has no `typeKey` to gate on (that is the later
9
+ * permission-management milestone). So the routes are `@Public()` to the
10
+ * classification guard (not object-gated), but NOT unauthenticated.
11
+ *
12
+ * ── AUTH (fail-closed) ── the caller is authenticated by resolving the bearer token
13
+ * DIRECTLY via the {@link SessionTokenBinder} (`binder.resolve(token)`), NOT via
14
+ * `resolveRequestPrincipal` — because change-password needs the caller's
15
+ * `sessionId` (to keep the current session alive while sweeping the others), and
16
+ * `resolveRequestPrincipal` collapses the claims down to a `Principal` (dropping
17
+ * `sessionId`). No token / no binder / resolve → null → generic 401.
18
+ *
19
+ * ── SEAMS (fail-closed → 501) ── the credential read+write goes through the
20
+ * consumer-plugged {@link CredentialWriter} (`getPasswordHash` + `setPassword`, now
21
+ * password-only); the current-password verify uses the {@link PasswordHasher};
22
+ * sessions are swept by the {@link SessionSweeper}. The {@link RecoveryCodeStore},
23
+ * the core {@link FactorVerifier} (single-secret TOTP, for per-device code checks),
24
+ * the {@link ProfileResolver} (otpauth label), and the {@link MfaDeviceStore}
25
+ * (the N-device persistence) are injected here for the multi-device MFA routes. Each
26
+ * is `@Optional()`; when a required seam is absent the route returns an honest 501
27
+ * (`not_available`) rather than a partial mutation.
28
+ *
29
+ * NOTE: TOTP secrets no longer live on the {@link CredentialWriter} — the
30
+ * multi-device MFA model stores every authenticator secret in the `MfaDeviceStore`.
31
+ * The old single-secret `mfa/enroll/{begin,confirm}` + `mfa/disable` routes were
32
+ * removed; the multi-device `/account/mfa/devices/*` routes replace them.
33
+ *
34
+ * ── ROUTES ── `POST /account/change-password` (change own password);
35
+ * `PATCH /account/profile` (edit own display name/email → the refreshed display
36
+ * profile); the multi-device MFA routes — `GET /account/mfa/devices` (list),
37
+ * `POST /account/mfa/devices/begin` (enroll), `POST /account/mfa/devices/confirm`
38
+ * (activate + first-device recovery codes), `PATCH /account/mfa/devices/:id/rename`,
39
+ * `DELETE /account/mfa/devices/:id` (code-gated), `POST
40
+ * /account/mfa/recovery-codes/regenerate` (code-gated).
41
+ *
42
+ * ── NO ORACLE ── a wrong current password is an indistinguishable generic 401
43
+ * (same body as no-token). We never reveal whether the principal exists, nor which
44
+ * check failed. Passwords/hashes are NEVER logged.
45
+ */
46
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
47
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
48
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
49
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
50
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
51
+ };
52
+ var __metadata = (this && this.__metadata) || function (k, v) {
53
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
54
+ };
55
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
56
+ return function (target, key) { decorator(target, key, paramIndex); }
57
+ };
58
+ Object.defineProperty(exports, "__esModule", { value: true });
59
+ exports.AccountController = void 0;
60
+ const common_1 = require("@nestjs/common");
61
+ const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
62
+ const request_principal_js_1 = require("../request-auth/request-principal.js");
63
+ const tokens_js_1 = require("../request-auth/tokens.js");
64
+ const tokens_js_2 = require("./tokens.js");
65
+ const totp_enrollment_js_1 = require("./totp-enrollment.js");
66
+ /** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
67
+ const MAX_DEVICE_NAME_LEN = 120;
68
+ /** Minimum acceptable new-password length (the only policy this route enforces). */
69
+ const MIN_PASSWORD_LEN = 8;
70
+ /**
71
+ * The issuer name embedded in the `otpauth://` provisioning URI (shown in the user's
72
+ * authenticator app as the account's service). A constant for the framework surface;
73
+ * a deployment that white-labels can lift this to config later without touching the
74
+ * enrollment flow.
75
+ */
76
+ const TOTP_ISSUER = 'Astram';
77
+ /**
78
+ * The otpauth account label used when the caller's display identity can't be resolved
79
+ * (no profile resolver, or it returns null). Deliberately a NON-UUID generic string —
80
+ * the raw principal id must NEVER become the label (it ends up in the URI shown to the
81
+ * user, and the never-show-UUIDs rule forbids surfacing a `*_uuid`).
82
+ */
83
+ const FALLBACK_OTPAUTH_LABEL = 'account';
84
+ /**
85
+ * The SINGLE generic 401 for EVERY unauthenticated/bad-credential path here (no
86
+ * token, no binder, resolve→null, wrong current password). Deliberately
87
+ * non-distinguishing — never leaks whether the principal exists or which check
88
+ * failed.
89
+ */
90
+ function unauthorized() {
91
+ throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
92
+ }
93
+ /** The honest 501 when a required seam is unplugged (fail-closed, no mutation). */
94
+ function notAvailable() {
95
+ throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
96
+ }
97
+ /**
98
+ * The generic 400 for a bad MFA request (no pending enrollment / wrong or missing
99
+ * code). Like {@link unauthorized}, deliberately non-distinguishing — it never
100
+ * reveals WHICH check failed (no pending vs wrong code), so it is no oracle. No
101
+ * mutation happens on this path.
102
+ */
103
+ function badRequest() {
104
+ throw new common_1.HttpException({ error: 'invalid_request' }, common_1.HttpStatus.BAD_REQUEST);
105
+ }
106
+ /**
107
+ * The 422 for a semantically invalid profile edit — an empty change (`no_changes`)
108
+ * or a malformed email (`invalid_email`). Distinct from {@link badRequest}: this is
109
+ * a self-scoped edit of the caller's OWN profile (no credential oracle to protect),
110
+ * so a precise field-level reason is safe and helpful for the form.
111
+ */
112
+ function unprocessable(error) {
113
+ throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
114
+ }
115
+ let AccountController = class AccountController {
116
+ binder;
117
+ hasher;
118
+ writer;
119
+ sweeper;
120
+ recoveryCodes;
121
+ totpVerifier;
122
+ profileResolver;
123
+ profileWriter;
124
+ mfaDevices;
125
+ constructor(binder, hasher, writer, sweeper, recoveryCodes, totpVerifier, profileResolver, profileWriter, mfaDevices) {
126
+ this.binder = binder;
127
+ this.hasher = hasher;
128
+ this.writer = writer;
129
+ this.sweeper = sweeper;
130
+ this.recoveryCodes = recoveryCodes;
131
+ this.totpVerifier = totpVerifier;
132
+ this.profileResolver = profileResolver;
133
+ this.profileWriter = profileWriter;
134
+ this.mfaDevices = mfaDevices;
135
+ }
136
+ /**
137
+ * Resolve a human-readable otpauth account LABEL for the caller (email, else display
138
+ * name) via the profile resolver. Best-effort: no resolver / null profile / a thrown
139
+ * store error → the generic {@link FALLBACK_OTPAUTH_LABEL}. NEVER returns the raw
140
+ * principal id — the label is shown to the user (in the URI), so a `*_uuid` there
141
+ * would violate the never-show-UUIDs rule.
142
+ */
143
+ async resolveOtpauthLabel(claims) {
144
+ if (this.profileResolver === undefined || this.profileResolver === null) {
145
+ return FALLBACK_OTPAUTH_LABEL;
146
+ }
147
+ try {
148
+ const profile = await this.profileResolver.resolve({
149
+ id: claims.id,
150
+ tenantId: claims.tenantId,
151
+ roleIds: [...claims.roleIds],
152
+ kind: 'user',
153
+ isRoot: false,
154
+ });
155
+ const label = profile?.email ?? profile?.name;
156
+ return typeof label === 'string' && label.length > 0 ? label : FALLBACK_OTPAUTH_LABEL;
157
+ }
158
+ catch {
159
+ // Fail-soft: a profile-store hiccup must not block enrollment — use the generic
160
+ // label rather than leak the id or error out.
161
+ return FALLBACK_OTPAUTH_LABEL;
162
+ }
163
+ }
164
+ /**
165
+ * Resolve the bearer token → the caller's FULL claims (id + sessionId), or throw a
166
+ * generic 401. Uses the binder DIRECTLY (not `resolveRequestPrincipal`) so the
167
+ * `sessionId` survives — change-password needs it to keep the current session.
168
+ */
169
+ async requireClaims(authorization) {
170
+ if (this.binder === undefined || this.binder === null)
171
+ unauthorized();
172
+ const token = (0, request_principal_js_1.extractBearerToken)(authorization);
173
+ if (token === null)
174
+ unauthorized();
175
+ const claims = await this.binder.resolve(token);
176
+ if (claims === null)
177
+ unauthorized();
178
+ return claims;
179
+ }
180
+ /**
181
+ * ENFORCED-RE-ENROLL GATE for the self-scoped `/account/*` surface (spec §4.2).
182
+ *
183
+ * WHY IT LIVES HERE (not only in the GlobalEnforcementGuard): every `/account/*`
184
+ * route is `@Public()` to the classification guard (it self-authenticates via
185
+ * {@link requireClaims}), so the guard's `mustReenroll` gate — which fires only on
186
+ * `@Classified` routes — NEVER runs on this surface. Without this chokepoint, a
187
+ * recovery-login (`mustReenroll:true`) session could call the account API DIRECTLY —
188
+ * e.g. `DELETE /account/mfa/devices/:id` to strip every device + wipe recovery codes
189
+ * WITHOUT confirming a fresh device — defeating the whole safeguard (spec §4.2:
190
+ * "calling the API directly cannot bypass it").
191
+ *
192
+ * WHY A PER-HANDLER BOOLEAN (not path matching): each handler declares its OWN
193
+ * re-enroll policy via a literal `allowedDuringReenroll`. This is unambiguous and
194
+ * cannot be fooled by URL/path normalization — there is no string matching to trick.
195
+ *
196
+ * POLICY (spec §4.2 + hardening): a `mustReenroll` session may ADD a device
197
+ * (`begin`/`confirm`), REGENERATE codes, and READ its devices — but must NOT
198
+ * REMOVE devices, rename them, change the password, or edit the profile. Removing
199
+ * devices is the exact attack vector (it strips MFA), so `removeDevice` is BLOCKED.
200
+ *
201
+ * A normal (`mustReenroll:false`) session is unaffected — this is a no-op for it.
202
+ */
203
+ enforceReenroll(claims, allowedDuringReenroll) {
204
+ if (claims.mustReenroll === true && !allowedDuringReenroll) {
205
+ throw new common_1.HttpException({ error: 'reenrollment_required' }, common_1.HttpStatus.FORBIDDEN);
206
+ }
207
+ }
208
+ /**
209
+ * `POST /account/change-password` — change the AUTHENTICATED caller's own password.
210
+ * Verifies the current password (read by principal id via the writer seam), writes
211
+ * the new hash, then revokes the caller's OTHER sessions (keeping the current one
212
+ * live). Self-scoped: acts only on `claims.id`.
213
+ *
214
+ * Outcomes:
215
+ * • no/invalid token → 401 (generic)
216
+ * • newPassword shorter than the minimum → 422 `{ error: 'weak_password' }`
217
+ * • writer/hasher seam absent → 501 `{ error: 'not_available' }`
218
+ * • wrong current password → 401 (generic, no write)
219
+ * • success → 204 (no body)
220
+ */
221
+ async changePassword(authorization, body) {
222
+ const claims = await this.requireClaims(authorization);
223
+ // BLOCKED during re-enroll: a recovery session must re-secure (add a device) before
224
+ // it can change the password.
225
+ this.enforceReenroll(claims, false);
226
+ const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '';
227
+ const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : '';
228
+ if (newPassword.length < MIN_PASSWORD_LEN) {
229
+ throw new common_1.HttpException({ error: 'weak_password' }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
230
+ }
231
+ // Fail-closed when the write side or the hasher is unplugged — never a partial
232
+ // mutation, an honest "not available" instead.
233
+ if (this.writer === undefined || this.writer === null || this.hasher === undefined) {
234
+ notAvailable();
235
+ }
236
+ const writer = this.writer;
237
+ const hasher = this.hasher;
238
+ // Verify the current password: read the stored hash BY PRINCIPAL ID via the writer
239
+ // seam's `getPasswordHash` (change-password holds the principal id from the token,
240
+ // whereas CredentialResolver keys by identityHint). A missing hash or a mismatch is
241
+ // the SAME generic 401 (no oracle), and no write happens.
242
+ const stored = await writer.getPasswordHash(claims.id);
243
+ if (stored === null || !(await hasher.verify(stored, currentPassword))) {
244
+ unauthorized();
245
+ }
246
+ const newHash = await hasher.hash(newPassword);
247
+ await writer.setPassword(claims.id, newHash);
248
+ // Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
249
+ // absent seam → skip (the password is already changed).
250
+ if (this.sweeper !== undefined && this.sweeper !== null) {
251
+ await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
252
+ }
253
+ }
254
+ /**
255
+ * Verify a presented TOTP `code` against a base32 `secret` using the injected core
256
+ * verifier (the SAME RFC-6238 verifier the login MFA branch uses). Returns the
257
+ * verdict's `satisfied` boolean; a malformed code/secret → `false` (the verifier
258
+ * never throws). NEVER logs the code or the secret.
259
+ */
260
+ async verifyTotp(secret, code) {
261
+ if (this.totpVerifier === undefined || this.totpVerifier === null)
262
+ return false;
263
+ const verdict = await this.totpVerifier.verify({ factor: 'totp', payload: { code } }, secret);
264
+ return verdict.satisfied;
265
+ }
266
+ /**
267
+ * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN display profile
268
+ * (name and/or email). Self-scoped: acts only on `claims.id`. On success returns the
269
+ * refreshed {@link CurrentUserProfile} (display names, never UUIDs — the bare `id`
270
+ * fields are machine-only), so the UI can update the header/user card in place.
271
+ *
272
+ * Only the fields actually supplied are written (partial edit). At least one of
273
+ * `name`/`email` must be present and non-empty; a supplied email must look like an
274
+ * email. These are semantic validations on the caller's OWN profile (no credential
275
+ * oracle), so they return precise 422 reasons rather than a generic 4xx.
276
+ *
277
+ * Outcomes:
278
+ * • no/invalid token → 401 (generic)
279
+ * • profileWriter seam absent → 501 `{ error: 'not_available' }`
280
+ * • neither name nor email supplied → 422 `{ error: 'no_changes' }`
281
+ * • email supplied but malformed → 422 `{ error: 'invalid_email' }`
282
+ * • success → 200 (the refreshed CurrentUserProfile)
283
+ */
284
+ async updateProfile(authorization, body) {
285
+ const claims = await this.requireClaims(authorization);
286
+ // BLOCKED during re-enroll: profile edits are not part of re-securing the account.
287
+ this.enforceReenroll(claims, false);
288
+ // Normalize: treat only non-empty string fields as a real change (trim so a
289
+ // whitespace-only value is not a change).
290
+ const name = typeof body?.name === 'string' && body.name.trim().length > 0
291
+ ? body.name.trim()
292
+ : undefined;
293
+ const email = typeof body?.email === 'string' && body.email.trim().length > 0
294
+ ? body.email.trim()
295
+ : undefined;
296
+ // Nothing to update → explicit 422 (rather than a silent no-op re-read).
297
+ if (name === undefined && email === undefined) {
298
+ unprocessable('no_changes');
299
+ }
300
+ // Basic email shape check when an email is supplied (non-empty already ensured).
301
+ if (email !== undefined && !isEmailShaped(email)) {
302
+ unprocessable('invalid_email');
303
+ }
304
+ // Fail-closed when the write seam is unplugged — an honest "not available".
305
+ if (this.profileWriter === undefined || this.profileWriter === null) {
306
+ notAvailable();
307
+ }
308
+ // Build the change with ONLY the supplied fields (partial edit), then return the
309
+ // refreshed display profile the writer re-reads (names, never UUIDs).
310
+ const changes = {};
311
+ if (name !== undefined)
312
+ changes.name = name;
313
+ if (email !== undefined)
314
+ changes.email = email;
315
+ return this.profileWriter.update(claims.id, changes);
316
+ }
317
+ // ───────────────────────────────────────────────────────────────────────────
318
+ // Multi-device MFA — self-scoped device management. Every route
319
+ // authenticates the caller (bearer → claims.id) and acts ONLY on the caller's
320
+ // own devices; a device not owned by the caller → 404 (never confirms another
321
+ // principal's device exists). A device `secret` is NEVER returned except the
322
+ // freshly-minted one at `begin` (the caller's own, for their QR). The store
323
+ // (`SHIELD_MFA_DEVICE_STORE`) absent → 501.
324
+ // ───────────────────────────────────────────────────────────────────────────
325
+ /**
326
+ * Mint a fresh recovery-code batch, hash each (the SAME Argon2 hasher recovery-code
327
+ * consume verifies against), and REPLACE the caller's prior codes. Returns the RAW
328
+ * codes to show the user exactly once. Caller guarantees the recovery store + hasher
329
+ * are present (checked at the route). NEVER logs the codes or hashes.
330
+ */
331
+ async replaceRecoveryCodes(principalId, store, hasher) {
332
+ const rawCodes = (0, totp_enrollment_js_1.generateRecoveryCodes)();
333
+ const hashes = await Promise.all(rawCodes.map((c) => hasher.hash(c)));
334
+ await store.replaceAll(principalId, hashes);
335
+ return rawCodes;
336
+ }
337
+ /**
338
+ * Verify a presented `code` against ANY of the caller's CONFIRMED device secrets
339
+ * (TOTP), returning true on the first match. Used by remove/regenerate where the
340
+ * proof may come from any live device (not a specific one). NEVER logs the code.
341
+ */
342
+ async verifyAgainstAnyDevice(store, principalId, code) {
343
+ const secrets = await store.listConfirmedSecrets(principalId);
344
+ for (const secret of secrets) {
345
+ if (await this.verifyTotp(secret, code))
346
+ return true;
347
+ }
348
+ return false;
349
+ }
350
+ /**
351
+ * `GET /account/mfa/devices` — list the caller's CONFIRMED authenticator devices.
352
+ * `mfaEnabled` reflects whether any confirmed device exists. Pending (unconfirmed)
353
+ * devices are internal and never surfaced. A device `secret` is never included
354
+ * (the {@link MfaDevice} display shape omits it).
355
+ *
356
+ * Outcomes: no/invalid token → 401; store absent → 501; success → 200
357
+ * `{ devices: MfaDevice[], mfaEnabled: boolean }`.
358
+ */
359
+ async listDevices(authorization) {
360
+ const claims = await this.requireClaims(authorization);
361
+ // ALLOWED during re-enroll: the re-enroll screen lists devices to show progress.
362
+ this.enforceReenroll(claims, true);
363
+ if (this.mfaDevices === undefined || this.mfaDevices === null)
364
+ notAvailable();
365
+ const all = await this.mfaDevices.listAll(claims.id);
366
+ const devices = all.filter((d) => d.confirmed);
367
+ return { devices, mfaEnabled: devices.length > 0 };
368
+ }
369
+ /**
370
+ * `POST /account/mfa/devices/begin` — start enrolling a NEW authenticator device.
371
+ * Mints a fresh secret, creates a PENDING device row, and returns the provisioning
372
+ * material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
373
+ * `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
374
+ *
375
+ * Outcomes: 401; store absent → 501; success → 200 `{ deviceId, otpauthUri, secret }`.
376
+ */
377
+ async beginDevice(authorization, body) {
378
+ const claims = await this.requireClaims(authorization);
379
+ // ALLOWED during re-enroll: adding a fresh device is the whole point of re-enroll.
380
+ this.enforceReenroll(claims, true);
381
+ if (this.mfaDevices === undefined || this.mfaDevices === null)
382
+ notAvailable();
383
+ const store = this.mfaDevices;
384
+ const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
385
+ const name = rawName.length > 0
386
+ ? rawName.slice(0, MAX_DEVICE_NAME_LEN)
387
+ : `Authenticator ${(await store.countConfirmed(claims.id)) + 1}`;
388
+ const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
389
+ const deviceId = await store.begin(claims.id, name, secret);
390
+ const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
391
+ return { deviceId, otpauthUri, secret };
392
+ }
393
+ /**
394
+ * `POST /account/mfa/devices/confirm` — activate a pending device by proving a live
395
+ * code from it. Loads THAT device's secret (owned + still pending), verifies the
396
+ * code, and confirms. If this is the caller's FIRST confirmed device, fresh recovery
397
+ * codes are minted (shown once) and returned; subsequent devices return `{}` and do
398
+ * NOT touch recovery codes.
399
+ *
400
+ * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned → 404;
401
+ * wrong/expired code → 400 (device stays pending); success → 200 `{ recoveryCodes? }`.
402
+ */
403
+ async confirmDevice(authorization, body) {
404
+ const claims = await this.requireClaims(authorization);
405
+ // ALLOWED during re-enroll: confirming a fresh device is the action that RE-SECURES
406
+ // the account — and (below) the SOLE action that clears the re-enroll restriction.
407
+ this.enforceReenroll(claims, true);
408
+ if (this.mfaDevices === undefined ||
409
+ this.mfaDevices === null ||
410
+ this.recoveryCodes === undefined ||
411
+ this.recoveryCodes === null ||
412
+ this.hasher === undefined) {
413
+ notAvailable();
414
+ }
415
+ const store = this.mfaDevices;
416
+ const recoveryStore = this.recoveryCodes;
417
+ const hasher = this.hasher;
418
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : '';
419
+ const code = typeof body?.code === 'string' ? body.code : '';
420
+ // Load the pending device's OWN secret (owned by the caller) — else 404. The secret
421
+ // never leaves the server; it is only used to check the presented code here.
422
+ const secret = await store.getSecret(claims.id, deviceId);
423
+ if (secret === null) {
424
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
425
+ }
426
+ // Wrong/expired code → generic 400; the device stays pending (retry or GC later).
427
+ if (!(await this.verifyTotp(secret, code)))
428
+ badRequest();
429
+ // First-device check BEFORE confirming (so the just-confirmed device isn't counted).
430
+ const wasFirst = (await store.countConfirmed(claims.id)) === 0;
431
+ const confirmed = await store.confirm(claims.id, deviceId);
432
+ // A race (device removed / already confirmed between load and confirm) → 404.
433
+ if (!confirmed) {
434
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
435
+ }
436
+ // ENFORCED-RE-ENROLL CLEAR (spec §4.3): confirming a fresh device is the SOLE
437
+ // action that lifts a recovery-login session's re-enroll restriction. If this
438
+ // caller's session is marked `mustReenroll`, clear the bound claim so its very next
439
+ // request is unrestricted (the access token is NOT re-minted — only the claim
440
+ // flips). Best-effort: a clear failure just means the user re-confirms/retries. The
441
+ // binder is present under `auth: true` (it authenticated `requireClaims` above);
442
+ // guarded for wiring resilience. Regenerate-codes deliberately does NOT clear.
443
+ if (claims.mustReenroll === true && this.binder !== undefined && this.binder !== null) {
444
+ await this.binder.clearReenroll(claims.sessionId);
445
+ }
446
+ if (wasFirst) {
447
+ const recoveryCodes = await this.replaceRecoveryCodes(claims.id, recoveryStore, hasher);
448
+ return { recoveryCodes };
449
+ }
450
+ // Subsequent devices never mint / wipe recovery codes.
451
+ return {};
452
+ }
453
+ /**
454
+ * `PATCH /account/mfa/devices/:id/rename` — rename one of the caller's devices.
455
+ * Cosmetic (renaming a label doesn't weaken 2FA), so it is session-auth only (no
456
+ * code required), consistent with profile-edit. Trimmed empty name → 422.
457
+ *
458
+ * Outcomes: 401; store absent → 501; blank name → 422; not found/owned → 404;
459
+ * success → 200 `{ device }`.
460
+ */
461
+ async renameDevice(authorization, id, body) {
462
+ const claims = await this.requireClaims(authorization);
463
+ // BLOCKED during re-enroll: renaming is not part of re-securing the account.
464
+ this.enforceReenroll(claims, false);
465
+ if (this.mfaDevices === undefined || this.mfaDevices === null)
466
+ notAvailable();
467
+ const store = this.mfaDevices;
468
+ const name = typeof body?.name === 'string' ? body.name.trim() : '';
469
+ if (name.length === 0)
470
+ unprocessable('invalid_name');
471
+ const device = await store.rename(claims.id, id, name.slice(0, MAX_DEVICE_NAME_LEN));
472
+ if (device === null) {
473
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
474
+ }
475
+ return { device };
476
+ }
477
+ /**
478
+ * `DELETE /account/mfa/devices/:id` — remove one of the caller's devices. Requires
479
+ * proof: a live TOTP code from ANY of the caller's remaining confirmed devices, OR a
480
+ * recovery code — so a hijacked live session can't silently strip MFA. Removing the
481
+ * LAST device is the "disable MFA" path (authorized by that last device's own code)
482
+ * and additionally wipes the account's recovery codes.
483
+ *
484
+ * Outcomes: 401; store absent → 501; wrong code → 400 (no remove); not found/owned →
485
+ * 404; success → 204.
486
+ */
487
+ async removeDevice(authorization, id, body) {
488
+ const claims = await this.requireClaims(authorization);
489
+ // BLOCKED during re-enroll (THE exploit the safeguard exists to stop): a recovery
490
+ // session must NOT be able to strip devices / disable MFA before re-securing. It
491
+ // must add + confirm a fresh device first (which clears the flag), THEN it may
492
+ // remove devices.
493
+ this.enforceReenroll(claims, false);
494
+ if (this.mfaDevices === undefined || this.mfaDevices === null)
495
+ notAvailable();
496
+ const store = this.mfaDevices;
497
+ const code = typeof body?.code === 'string' ? body.code : '';
498
+ // Proof BEFORE the delete: a TOTP from any confirmed device (incl. the one being
499
+ // removed — legit for the last-device disable) OR a valid recovery code.
500
+ const byTotp = await this.verifyAgainstAnyDevice(store, claims.id, code);
501
+ const byRecovery = !byTotp &&
502
+ this.recoveryCodes !== undefined &&
503
+ this.recoveryCodes !== null &&
504
+ (await this.recoveryCodes.consume(claims.id, code));
505
+ if (!byTotp && !byRecovery)
506
+ badRequest();
507
+ const removed = await store.remove(claims.id, id);
508
+ if (!removed) {
509
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
510
+ }
511
+ // Removing the last confirmed device disables MFA → wipe the account recovery codes.
512
+ if ((await store.countConfirmed(claims.id)) === 0) {
513
+ if (this.recoveryCodes !== undefined && this.recoveryCodes !== null) {
514
+ await this.recoveryCodes.replaceAll(claims.id, []);
515
+ }
516
+ }
517
+ }
518
+ /**
519
+ * `POST /account/mfa/recovery-codes/regenerate` — mint a fresh recovery-code batch
520
+ * (replacing the old set). Requires MFA to be enabled (≥1 confirmed device) and proof
521
+ * — a TOTP from any confirmed device OR an existing recovery code.
522
+ *
523
+ * Outcomes: 401; store/recovery/hasher absent → 501; MFA off → 400; wrong code → 400;
524
+ * success → 200 `{ recoveryCodes }`.
525
+ */
526
+ async regenerateRecoveryCodes(authorization, body) {
527
+ const claims = await this.requireClaims(authorization);
528
+ // ALLOWED during re-enroll: the user may refresh recovery codes while re-securing.
529
+ // (Allowed ≠ gate-clearing, spec §4.3: regenerating codes does NOT lift the
530
+ // restriction — only confirming a fresh device does.)
531
+ this.enforceReenroll(claims, true);
532
+ if (this.mfaDevices === undefined ||
533
+ this.mfaDevices === null ||
534
+ this.recoveryCodes === undefined ||
535
+ this.recoveryCodes === null ||
536
+ this.hasher === undefined) {
537
+ notAvailable();
538
+ }
539
+ const store = this.mfaDevices;
540
+ const recoveryStore = this.recoveryCodes;
541
+ const hasher = this.hasher;
542
+ const code = typeof body?.code === 'string' ? body.code : '';
543
+ // MFA must be on — else there's nothing to protect / no code source → generic 400.
544
+ if ((await store.countConfirmed(claims.id)) === 0)
545
+ badRequest();
546
+ // Proof: a TOTP from any confirmed device OR an existing recovery code.
547
+ const byTotp = await this.verifyAgainstAnyDevice(store, claims.id, code);
548
+ const byRecovery = !byTotp && (await recoveryStore.consume(claims.id, code));
549
+ if (!byTotp && !byRecovery)
550
+ badRequest();
551
+ const recoveryCodes = await this.replaceRecoveryCodes(claims.id, recoveryStore, hasher);
552
+ return { recoveryCodes };
553
+ }
554
+ };
555
+ exports.AccountController = AccountController;
556
+ __decorate([
557
+ (0, classification_decorator_js_1.Public)(),
558
+ (0, common_1.Post)('change-password'),
559
+ (0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
560
+ __param(0, (0, common_1.Headers)('authorization')),
561
+ __param(1, (0, common_1.Body)()),
562
+ __metadata("design:type", Function),
563
+ __metadata("design:paramtypes", [Object, Object]),
564
+ __metadata("design:returntype", Promise)
565
+ ], AccountController.prototype, "changePassword", null);
566
+ __decorate([
567
+ (0, classification_decorator_js_1.Public)(),
568
+ (0, common_1.Patch)('profile'),
569
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
570
+ __param(0, (0, common_1.Headers)('authorization')),
571
+ __param(1, (0, common_1.Body)()),
572
+ __metadata("design:type", Function),
573
+ __metadata("design:paramtypes", [Object, Object]),
574
+ __metadata("design:returntype", Promise)
575
+ ], AccountController.prototype, "updateProfile", null);
576
+ __decorate([
577
+ (0, classification_decorator_js_1.Public)(),
578
+ (0, common_1.Get)('mfa/devices'),
579
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
580
+ __param(0, (0, common_1.Headers)('authorization')),
581
+ __metadata("design:type", Function),
582
+ __metadata("design:paramtypes", [Object]),
583
+ __metadata("design:returntype", Promise)
584
+ ], AccountController.prototype, "listDevices", null);
585
+ __decorate([
586
+ (0, classification_decorator_js_1.Public)(),
587
+ (0, common_1.Post)('mfa/devices/begin'),
588
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
589
+ __param(0, (0, common_1.Headers)('authorization')),
590
+ __param(1, (0, common_1.Body)()),
591
+ __metadata("design:type", Function),
592
+ __metadata("design:paramtypes", [Object, Object]),
593
+ __metadata("design:returntype", Promise)
594
+ ], AccountController.prototype, "beginDevice", null);
595
+ __decorate([
596
+ (0, classification_decorator_js_1.Public)(),
597
+ (0, common_1.Post)('mfa/devices/confirm'),
598
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
599
+ __param(0, (0, common_1.Headers)('authorization')),
600
+ __param(1, (0, common_1.Body)()),
601
+ __metadata("design:type", Function),
602
+ __metadata("design:paramtypes", [Object, Object]),
603
+ __metadata("design:returntype", Promise)
604
+ ], AccountController.prototype, "confirmDevice", null);
605
+ __decorate([
606
+ (0, classification_decorator_js_1.Public)(),
607
+ (0, common_1.Patch)('mfa/devices/:id/rename'),
608
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
609
+ __param(0, (0, common_1.Headers)('authorization')),
610
+ __param(1, (0, common_1.Param)('id')),
611
+ __param(2, (0, common_1.Body)()),
612
+ __metadata("design:type", Function),
613
+ __metadata("design:paramtypes", [Object, String, Object]),
614
+ __metadata("design:returntype", Promise)
615
+ ], AccountController.prototype, "renameDevice", null);
616
+ __decorate([
617
+ (0, classification_decorator_js_1.Public)(),
618
+ (0, common_1.Delete)('mfa/devices/:id'),
619
+ (0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
620
+ __param(0, (0, common_1.Headers)('authorization')),
621
+ __param(1, (0, common_1.Param)('id')),
622
+ __param(2, (0, common_1.Body)()),
623
+ __metadata("design:type", Function),
624
+ __metadata("design:paramtypes", [Object, String, Object]),
625
+ __metadata("design:returntype", Promise)
626
+ ], AccountController.prototype, "removeDevice", null);
627
+ __decorate([
628
+ (0, classification_decorator_js_1.Public)(),
629
+ (0, common_1.Post)('mfa/recovery-codes/regenerate'),
630
+ (0, common_1.HttpCode)(common_1.HttpStatus.OK),
631
+ __param(0, (0, common_1.Headers)('authorization')),
632
+ __param(1, (0, common_1.Body)()),
633
+ __metadata("design:type", Function),
634
+ __metadata("design:paramtypes", [Object, Object]),
635
+ __metadata("design:returntype", Promise)
636
+ ], AccountController.prototype, "regenerateRecoveryCodes", null);
637
+ exports.AccountController = AccountController = __decorate([
638
+ (0, common_1.Controller)('account'),
639
+ __param(0, (0, common_1.Optional)()),
640
+ __param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_TOKEN_BINDER)),
641
+ __param(1, (0, common_1.Optional)()),
642
+ __param(1, (0, common_1.Inject)(tokens_js_2.SHIELD_PASSWORD_HASHER)),
643
+ __param(2, (0, common_1.Optional)()),
644
+ __param(2, (0, common_1.Inject)(tokens_js_2.SHIELD_CREDENTIAL_WRITER)),
645
+ __param(3, (0, common_1.Optional)()),
646
+ __param(3, (0, common_1.Inject)(tokens_js_2.SHIELD_SESSION_SWEEPER)),
647
+ __param(4, (0, common_1.Optional)()),
648
+ __param(4, (0, common_1.Inject)(tokens_js_2.SHIELD_RECOVERY_CODE_STORE)),
649
+ __param(5, (0, common_1.Optional)()),
650
+ __param(5, (0, common_1.Inject)(tokens_js_2.SHIELD_TOTP_VERIFIER)),
651
+ __param(6, (0, common_1.Optional)()),
652
+ __param(6, (0, common_1.Inject)(tokens_js_2.SHIELD_PROFILE_RESOLVER)),
653
+ __param(7, (0, common_1.Optional)()),
654
+ __param(7, (0, common_1.Inject)(tokens_js_2.SHIELD_PROFILE_WRITER)),
655
+ __param(8, (0, common_1.Optional)()),
656
+ __param(8, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_DEVICE_STORE)),
657
+ __metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object])
658
+ ], AccountController);
659
+ /**
660
+ * A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
661
+ * domain, no whitespace). This is a self-service edit of the caller's own profile —
662
+ * a full RFC-5322 validator adds no security value here, only false rejections. The
663
+ * authoritative check is delivery; this just catches obvious typos client-adjacent.
664
+ */
665
+ function isEmailShaped(value) {
666
+ if (/\s/.test(value))
667
+ return false;
668
+ const at = value.indexOf('@');
669
+ if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
670
+ return false;
671
+ const domain = value.slice(at + 1);
672
+ return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
673
+ }