@aooth/auth-moost 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,9 +1,10 @@
1
+ import { Mate, TInterceptorDef, TMateParamMeta, TMoostMetadata } from "moost";
1
2
  import { AuthContext, AuthContext as AuthContext$1, AuthCredential, AuthEmailEvent, AuthEmailKind, AuthEmailKind as AuthEmailKind$1, AuthSmsEvent, AuthSmsKind, AuthSmsKind as AuthSmsKind$1, BuildMagicLinkUrl, BuildMagicLinkUrl as BuildMagicLinkUrl$1, EmailSender, EmailSender as EmailSender$1, IssueResult, IssueResult as IssueResult$1, SmsSender, generateMagicLinkToken } from "@aooth/auth";
2
3
  import * as _$_wooksjs_event_core0 from "@wooksjs/event-core";
3
4
  import { TCookieAttributesInput } from "@wooksjs/event-http";
4
- import { Mate, TInterceptorDef, TMateParamMeta, TMoostMetadata } from "moost";
5
- import { MoostWf, WfOutlet, WfOutletTokenConfig, WfStateStrategy } from "@moostjs/event-wf";
6
5
  import { TrustedDeviceRecord, UserCredentials, UserService } from "@aooth/user";
6
+ import { WfFinished } from "@atscript/moost-wf";
7
+ import { MoostWf, WfOutlet, WfOutletTokenConfig, WfStateStrategy } from "@moostjs/event-wf";
7
8
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
8
9
 
9
10
  //#region src/auth.config.d.ts
@@ -46,6 +47,431 @@ interface ResolvedAuthOptions {
46
47
  enableBearer: boolean;
47
48
  }
48
49
  //#endregion
50
+ //#region src/auth.opts.d.ts
51
+ declare class AuthOpts {
52
+ /** Pincode infrastructure shared by login MFA, invite MFA, and recovery OTP. */
53
+ mfa: {
54
+ pincodeLength: number;
55
+ pincodeTtlMs: number;
56
+ pincodeResendTimeoutMs: number;
57
+ };
58
+ /** Magic-link TTL shared by login (alt-credentials), invite, recovery. */
59
+ magicLinkTtlMs: number;
60
+ /** Canonical login URL — used by invite (post-accept redirect) and recovery (abort-to-login + post-reset redirect) as the resolver-default loginUrl. */
61
+ loginUrl: string;
62
+ /** TOTP provisioning issuer — used by login MFA and invite MFA enrollment. */
63
+ totpIssuer: string;
64
+ }
65
+ //#endregion
66
+ //#region src/workflows/auth-workflow.base.d.ts
67
+ /**
68
+ * Method names for the MFA enrollment helper. Re-exported from
69
+ * `login.workflow.options` as `MfaTransport` (kept as the public alias) so
70
+ * existing consumers don't need to switch import paths.
71
+ */
72
+ type MfaTransport = "sms" | "email" | "totp";
73
+ /**
74
+ * Context shape consumed by the `enrollPickPhase` / `enrollAddressPhase` /
75
+ * `enrollConfirmPhase` helpers. Both `LoginWfCtx` and
76
+ * `InviteWfCtx` extend this implicitly (they declare the same field set).
77
+ * Kept structural so neither workflow's full ctx union has to be imported
78
+ * here — base stays workflow-agnostic.
79
+ */
80
+ interface MfaEnrollCtx {
81
+ enrollMethod?: MfaTransport;
82
+ enrollAddress?: string;
83
+ enrollSecret?: string;
84
+ enrollUri?: string;
85
+ enrollAvailableTransports?: MfaTransport[];
86
+ enrollDone?: boolean;
87
+ pin?: string;
88
+ pinExpire?: number;
89
+ pinSentTo?: string;
90
+ /**
91
+ * Next-allowed-resend timestamp for the Phase 3 confirm pincode (sms/email
92
+ * only). Written by the Phase 2 initial send + the Phase 3 `resend`
93
+ * alt-action; consulted by `resend` to throttle re-emits. Mirrors the
94
+ * `pinTimeout` pattern used by the login `pincode-check-login` step.
95
+ */
96
+ enrollPincodeCooldown?: number;
97
+ }
98
+ /**
99
+ * Looser structural mirror of `DeliverPayload` from `login.workflow.ts`.
100
+ * The base file mustn't import from a sibling workflow file; the concrete
101
+ * workflow's strict discriminated union is structurally assignable to this.
102
+ */
103
+ interface DeliverPayloadLike {
104
+ channel: "email" | "sms";
105
+ kind: string;
106
+ recipient: string;
107
+ code?: string;
108
+ expiresAt?: number;
109
+ ttlMs?: number;
110
+ userId?: string;
111
+ }
112
+ interface MfaEnrollDeps {
113
+ ctx: MfaEnrollCtx;
114
+ username: string;
115
+ users: UserService;
116
+ /** Concrete workflow's `deliver` hook, narrowed to the payloads this flow emits. */
117
+ deliver: (payload: DeliverPayloadLike) => Promise<void>;
118
+ forms: {
119
+ pickMethod: TAtscriptAnnotatedType;
120
+ address: TAtscriptAnnotatedType;
121
+ confirm: TAtscriptAnnotatedType;
122
+ };
123
+ transports: MfaTransport[];
124
+ pincodeLength: number;
125
+ pincodeTtlMs: number;
126
+ /**
127
+ * Per-method resend cooldown for the Phase 3 confirm pincode (sms/email).
128
+ * Mirrors `LoginWorkflowOpts.mfa.pincodeResendTimeoutMs`.
129
+ */
130
+ pincodeResendTimeoutMs: number;
131
+ /** TOTP provisioning issuer (rendered in the authenticator app). */
132
+ issuer: string;
133
+ /**
134
+ * Enrollment policy. `'required'` runs through all 3 phases. `'optional'`
135
+ * additionally watches for a `skip` action on the Phase 1 pickMethod form —
136
+ * a skip click short-circuits by setting `ctx.enrollDone = true`. The
137
+ * caller is expected to gate this step out entirely when `mode === 'disabled'`.
138
+ */
139
+ mode: "required" | "optional";
140
+ /**
141
+ * Optional bridge fired at the end of `enrollConfirmPhase` (or on a skip in
142
+ * `'optional'` mode at any phase) — right after `enrollDone` flips true. Login
143
+ * uses this to mirror `enrollDone` → `mfaChecked` so its outer MFA while-loop
144
+ * (gated on `!mfaChecked`) exits. Invite omits it because its enrollment
145
+ * while-loop is gated on `!enrollDone` directly.
146
+ */
147
+ onComplete?: (ctx: MfaEnrollCtx) => void;
148
+ }
149
+ /** Workflow context shape expected by `mintPin` + `verifyPin`. */
150
+ interface PinCtx {
151
+ pin?: string;
152
+ pinExpire?: number;
153
+ }
154
+ /**
155
+ * Structural ctx shape consumed by `processInlineConsent`. Mirrors the
156
+ * relevant subset of the workflow ctx types so the helper stays
157
+ * workflow-agnostic. The helper consumes only the dynamic `pendingConsents`
158
+ * descriptor array (populated by `prepare-consents` from
159
+ * `ConsentStore.getPendingConsents()`) and the per-run booking fields it
160
+ * writes itself — the prior static `acceptance` / `termsVersion` branches
161
+ * were retired in Phase 6 along with the matching `ctx.acceptance` field
162
+ * on each workflow ctx type.
163
+ */
164
+ interface InlineConsentCtx {
165
+ /**
166
+ * Descriptors the user still needs to be prompted for — set once by
167
+ * `prepare-consents` after username-bind. Empty / unset ⇒ no consents to
168
+ * collect (helper short-circuits).
169
+ */
170
+ pendingConsents?: ConsentDescriptorLike[];
171
+ /**
172
+ * Subset of descriptor ids the user ticked on the carrier form. Set by
173
+ * `processInlineConsent` after silent-dropping unknown ids — `pendingConsents`
174
+ * is the server's whitelist, NOT what the client posts.
175
+ */
176
+ acceptedConsentIds?: string[];
177
+ /**
178
+ * Wall-clock ms at the moment `processInlineConsent` resolved the
179
+ * `consents` array. Captured here so the persisted `ConsentEvent.at`
180
+ * reflects user-action time, not write-time — surviving a paused
181
+ * workflow's resume gap.
182
+ */
183
+ consentsDecidedAt?: number;
184
+ /**
185
+ * Set true by `persist-consents` after the batched `consentStore.save`
186
+ * call fires (or after the step short-circuits with no pending consents).
187
+ * Gates the helper from re-staging on a subsequent carrier-form submission.
188
+ */
189
+ consentsPersisted?: boolean;
190
+ }
191
+ /**
192
+ * Structural alias of `ConsentDescriptor` — kept inline so this module
193
+ * doesn't import from `../consent.store.ts` (which would create a cycle:
194
+ * consent.store.ts already imports `ConsentEvent` from here).
195
+ */
196
+ interface ConsentDescriptorLike {
197
+ id: string;
198
+ text: string;
199
+ required?: string;
200
+ version?: string;
201
+ }
202
+ /**
203
+ * Subset of the carrier-form payload that `processInlineConsent` reads.
204
+ * Phase 5 replaces the pre-existing static `{ acceptedTerms?, marketingOptIn? }`
205
+ * pair with a single dynamic `consents: string[]` — the SUBSET of descriptor
206
+ * ids the user ticked in the `AsConsentArray` (`@atscript/vue-aooth`)
207
+ * component. The server reads `pendingConsents` from its own ctx (NOT from
208
+ * this input) to decide which ids are valid; unknown ids are silently
209
+ * dropped (audit-grade defense — see helper rationale).
210
+ */
211
+ interface InlineConsentInput {
212
+ consents?: string[];
213
+ }
214
+ /**
215
+ * Consent event emitted to the `ConsentStore.save(username, events)` DI
216
+ * provider. Storage shape is intentionally the consumer's call — Mongo users
217
+ * typically push the events onto an embedded array, SQL users insert into an
218
+ * audit table, event-bus users publish to a topic. The library batches all
219
+ * collected events from a single workflow run into one call: ONE event per
220
+ * pending descriptor (audit-friendly default — declined-optional consents
221
+ * are persisted too, so customers can prove the user was asked; customers
222
+ * who want only accepted events filter in their `save()` override). The
223
+ * `accepted` boolean is explicit per row — `true` when the user ticked the
224
+ * matching descriptor, `false` when an optional descriptor went un-ticked.
225
+ */
226
+ interface ConsentEvent {
227
+ /** Identifier from the matching `ConsentDescriptor.id`. */
228
+ id: string;
229
+ /** Whether the user ticked this descriptor (`false` for un-ticked optionals). */
230
+ accepted: boolean;
231
+ /** Stamped from the matching `ConsentDescriptor.version` (when set). */
232
+ version?: string;
233
+ /**
234
+ * Wall-clock ms at the moment `processInlineConsent` resolved the user's
235
+ * carrier-form submission (NOT at write-time — captured BEFORE the batched
236
+ * `consentStore.save` call so a paused-workflow resume gap doesn't drift
237
+ * the timestamp).
238
+ */
239
+ at: number;
240
+ }
241
+ /**
242
+ * Structural alias for `ReturnType<typeof useAtscriptWf>` — only the
243
+ * `requireInput` method is consumed by `processInlineConsent`, kept narrow
244
+ * so callers can pass any form's wf handle without TS choking on the form-
245
+ * specific `resolveInput` return type.
246
+ */
247
+ type WfRequireInputOnly = {
248
+ requireInput(opts?: {
249
+ errors?: Record<string, string>;
250
+ formMessage?: string;
251
+ }): unknown;
252
+ };
253
+ declare class AuthWorkflowBase {
254
+ /**
255
+ * Asserts `ctx.username` is populated. Workflow steps reach for `ctx.username`
256
+ * after `credentials`/`init` has set it; losing it indicates a workflow-state
257
+ * bug, not a client error. Throws `HttpError(500)` on miss; otherwise narrows
258
+ * the field to `string` for the caller via `asserts`.
259
+ */
260
+ protected requireUsername<T extends {
261
+ username?: string;
262
+ }>(ctx: T): asserts ctx is T & {
263
+ username: string;
264
+ };
265
+ /**
266
+ * Wrap an `UserStore` mutation that can race (`withCas`-backed paths:
267
+ * `consumeBackupCode`, `addMfaMethod`, `confirmMfaMethod`, `addTrustedDevice`)
268
+ * so a CAS retry-budget exhaustion surfaces as 409 Conflict — the canonical
269
+ * OCC status — rather than bubbling to the moost default 500. Client SHOULD
270
+ * retry; a 500 falsely implies the server is broken. Other `UserAuthError`
271
+ * shapes pass through unchanged so step-local catch blocks (e.g. ALREADY_EXISTS
272
+ * → 409 with a different reason) still see them.
273
+ */
274
+ protected withStoreErrorTranslation<T>(op: () => Promise<T>): Promise<T>;
275
+ /**
276
+ * Resolve the client IP from the active HTTP request, swallowing the case
277
+ * where there is no HTTP context (unit tests that hand-roll the wf runtime).
278
+ */
279
+ protected resolveClientIp(): string | undefined;
280
+ /**
281
+ * Mint a numeric pincode and stash it + its expiry onto `ctx`. Returns the
282
+ * code so the caller can hand it to the delivery transport.
283
+ */
284
+ protected mintPin(ctx: PinCtx, length: number, ttlMs: number): string;
285
+ /**
286
+ * Verify a submitted pincode against `ctx.pin`. Returns a `{ code: '…' }`
287
+ * error map on expired/invalid, or `null` on success. Callers wrap the result
288
+ * with `useAtscriptWf(PincodeForm).requireInput({ errors })`.
289
+ */
290
+ protected verifyPin(ctx: PinCtx, submitted: string | undefined): {
291
+ code: string;
292
+ } | null;
293
+ /**
294
+ * Validate + stash inline-consent fields submitted on a carrier form.
295
+ *
296
+ * SECURITY (silent-drop): the server reads its OWN `ctx.pendingConsents`
297
+ * (set once by `prepare-consents` from `ConsentStore.getPendingConsents()`)
298
+ * as the authoritative whitelist of valid descriptor ids. Any id in the
299
+ * user-submitted `input.consents` array that does NOT match a current
300
+ * pending descriptor is SILENTLY DROPPED — no error, no log, no signal
301
+ * back to the client. This preserves the audit-grade
302
+ * "what user saw is what server records" invariant: an attacker
303
+ * submitting `consents: ['terms', 'gdpr-forged-id']` against a descriptor
304
+ * list of only `['terms']` cannot forge an audit record for the
305
+ * never-displayed `'gdpr-forged-id'` consent. Surfacing the drop would
306
+ * leak the consent universe (probing surface), so the defense is silent.
307
+ *
308
+ * SECURITY (mandatory-by-message): each descriptor's `required` field is
309
+ * the load-bearing mandatory flag. A non-empty string means the consent
310
+ * is MANDATORY and that string IS the per-row error message — the
311
+ * `AsConsentArray` component surfaces it inline per descriptor; the
312
+ * server throws the SAME copy as a form-level error on the bound
313
+ * `consents` field when the first required descriptor is missing from
314
+ * the submitted set. Absent / empty `required` ⇒ optional consent — the
315
+ * un-ticked descriptor is still persisted as `{accepted: false}` (audit
316
+ * default — proves the user was asked).
317
+ *
318
+ * Idempotency: once `ctx.consentsPersisted` is true, the helper is a
319
+ * no-op. Same for `ctx.pendingConsents` being empty / unset — no
320
+ * pending = nothing to validate (the carrier-form's `AsConsentArray`
321
+ * also self-hides on empty `pendingConsents`).
322
+ */
323
+ protected processInlineConsent(ctx: InlineConsentCtx, input: InlineConsentInput, wf: WfRequireInputOnly): void;
324
+ /**
325
+ * Batched consent persistence — shared `persist-consents` step body for
326
+ * `LoginWorkflow` / `InviteWorkflow` / `RecoveryWorkflow`. Fans one
327
+ * `ConsentEvent` per pending descriptor out to the `ConsentStore.save`
328
+ * DI provider in a single call. Audit-friendly default: declined-optional
329
+ * consents are persisted with `accepted: false` (customers who want only
330
+ * accepted events filter in their `save()` override). `accepted` is
331
+ * derived per descriptor by `acceptedConsentIds.has(id)`. Idempotent via
332
+ * `ctx.consentsPersisted`; short-circuits with no events when
333
+ * `pendingConsents` is empty (defensive — the schema condition gates on
334
+ * `consentsDecidedAt` which is only set when pending was non-empty).
335
+ *
336
+ * Each workflow's `@Step("persist-consents")` method is a one-liner
337
+ * delegate to this helper — the @Step decorator must stay on the
338
+ * subclass so the wf engine registers the step id under the correct
339
+ * controller, but the body lives here once.
340
+ */
341
+ protected runPersistConsents(ctx: InlineConsentCtx & {
342
+ username?: string;
343
+ }, consentStore: ConsentStore): Promise<undefined>;
344
+ /**
345
+ * Phase 1 of MFA enrollment. Picks the method (auto-pick if only one
346
+ * transport, otherwise pause for the picker form), handles the `skip`
347
+ * alt-action in `'optional'` mode, and — when TOTP is picked — provisions
348
+ * the secret idempotently in the same step body so the next iteration can
349
+ * proceed straight to confirm. Sync-friendly return type because the
350
+ * auto-pick branch and the picker-form branch both stay synchronous; the
351
+ * TOTP-provisioning tail is the only async path.
352
+ *
353
+ * Atomic boundary: after this helper runs, `ctx.enrollMethod` is set AND
354
+ * (for totp) `ctx.enrollSecret` is provisioned. Confirm doesn't need to
355
+ * worry about provisioning.
356
+ */
357
+ protected enrollPickPhase(deps: MfaEnrollDeps): undefined | Promise<undefined>;
358
+ /**
359
+ * Phase 2 of MFA enrollment. Collects the sms/email address, persists it as
360
+ * an unconfirmed method, mints + dispatches the pincode. Handles `skip` /
361
+ * `useDifferentMethod` alt-actions. Not invoked for totp (no address to
362
+ * collect — the schema condition gates it out).
363
+ */
364
+ protected enrollAddressPhase(deps: MfaEnrollDeps): Promise<undefined>;
365
+ /**
366
+ * Phase 3 of MFA enrollment. Verifies the user-submitted code (TOTP or
367
+ * pincode), marks the method confirmed, sets it as the default, and flags
368
+ * `ctx.enrollDone = true`. Handles `skip` / `useDifferentMethod` / `resend`
369
+ * alt-actions (cleanup on the first two; resend re-mints + redispatches).
370
+ *
371
+ * Idempotently provisions the TOTP secret at the top when missing — covers
372
+ * the path where a consumer setter override (e.g. `inviteSetupMfa`)
373
+ * pre-picks totp, leaving pickPhase's schema gate closed; the secret IS
374
+ * what confirm needs, so confirm guarantees it exists. For sms/email the
375
+ * unconfirmed method row + pincode are written by addressPhase, so there's
376
+ * nothing to provision here.
377
+ */
378
+ protected enrollConfirmPhase(deps: MfaEnrollDeps): Promise<undefined>;
379
+ /**
380
+ * Send a pincode for the active sms/email enrollment method and stamp
381
+ * `ctx.pinSentTo` with the masked recipient. Shared by Phase 2 initial
382
+ * dispatch and Phase 3 `resend`. Caller is responsible for `mintPin` +
383
+ * stamping `enrollPincodeCooldown` BEFORE calling — this just dispatches.
384
+ * Not called for TOTP (no pincode to send).
385
+ */
386
+ protected sendEnrollPincode(ctx: MfaEnrollCtx, deps: MfaEnrollDeps, address: string, code: string): Promise<void>;
387
+ /**
388
+ * Cleanup any partially-persisted enrollment state (unconfirmed method row +
389
+ * ctx scratch). Called when the user picks `skip` or `useDifferentMethod`
390
+ * mid-flow on Phase 3, where the unconfirmed method has already been written
391
+ * via `addMfaMethod` (Phase 1 for totp, Phase 2 for sms/email). On
392
+ * `useDifferentMethod` the caller relies on `enrollMethod` being cleared so
393
+ * the loop re-enters Phase 1.
394
+ */
395
+ protected cleanupEnrollment(ctx: MfaEnrollCtx, users: UserService, username: string): Promise<void>;
396
+ }
397
+ //#endregion
398
+ //#region src/consent.store.d.ts
399
+ /**
400
+ * Descriptor for a single consent prompt. Customers' ConsentStore.getPendingConsents
401
+ * returns an array of these — the workflow transports them via `@wf.context.pass`
402
+ * to the SPA carrier form, which renders one row per descriptor through the
403
+ * `AsConsentArray` component (`@atscript/vue-aooth`). The user-submitted
404
+ * `consents: string[]` field on the carrier form posts back the SUBSET of
405
+ * `id`s the user accepted; the server-side `processInlineConsent` helper
406
+ * silently drops any ids that don't match a current descriptor (audit-grade
407
+ * "what user saw is what server records" invariant — preventing an attacker
408
+ * from forging audit rows for consents the user was never shown).
409
+ */
410
+ interface ConsentDescriptor {
411
+ /** Stable identifier — committed to the user-submitted `consents: string[]`
412
+ * array and stamped on the persisted `ConsentEvent` (customer-defined:
413
+ * `'terms'`, `'marketing'`, `'jurisdiction-gdpr'`, …). */
414
+ id: string;
415
+ /** User-facing label / disclosure text. Markdown links are allowed; the
416
+ * SPA component will sanitize-render. */
417
+ text: string;
418
+ /**
419
+ * When present and non-empty, the consent is MANDATORY and this string IS
420
+ * the per-row error message surfaced by `AsConsentArray` (and the
421
+ * server-side `processInlineConsent` form-level error) when the user
422
+ * submits without ticking it. Absent or empty string ⇒ optional consent —
423
+ * a `ConsentEvent` with `accepted: false` is still persisted so the audit
424
+ * record proves the user was asked.
425
+ */
426
+ required?: string;
427
+ /** Stamped onto persisted ConsentEvent for versioned policies (terms, ...). */
428
+ version?: string;
429
+ }
430
+ /**
431
+ * Injectable DI seam for consent persistence + the customer-defined consent
432
+ * universe. SINGLETON-scoped (one instance per app lifetime). All methods are
433
+ * no-op defaults — customers extend this class and replace via
434
+ * `createReplaceRegistry([ConsentStore, MyConsentStore])`.
435
+ */
436
+ declare class ConsentStore {
437
+ /**
438
+ * Returns descriptors for consents this user still needs to accept on the
439
+ * next prompt boundary. Empty array → no consent step renders. The workflow
440
+ * passes its identity + (optionally) the channel it's about to use so the
441
+ * customer's impl can prompt different consent sets per workflow/channel.
442
+ */
443
+ getPendingConsents(_username: string | undefined, _ctx: {
444
+ workflow: string;
445
+ channel?: "email" | "sms";
446
+ }): Promise<ConsentDescriptor[]>;
447
+ /**
448
+ * Persist a batch of captured consent events. Default: no-op. Override to
449
+ * write to your audit table / event store / whatever your legal team
450
+ * requires.
451
+ */
452
+ save(_username: string, _events: ConsentEvent[]): Promise<void>;
453
+ /**
454
+ * Read consent history for a user, optionally filtered by event id.
455
+ * Used by getPendingConsents impls that want to compute "has the user
456
+ * already accepted version vN" without maintaining a separate index.
457
+ */
458
+ read(_username: string, _filter?: {
459
+ id?: string;
460
+ }): Promise<ConsentEvent[]>;
461
+ /**
462
+ * Fired by LoginWorkflow's verify/:channel step AFTER pincode validates,
463
+ * i.e., AFTER channel ownership is confirmed. The disclosure text is the
464
+ * literal copy the user saw beneath the input field at ask/:channel time
465
+ * (resolveOtpDisclosure result) — passed through so the customer's record
466
+ * pins exactly what was shown to the user.
467
+ *
468
+ * Default: no-op. Disclosure-only is sufficient for transactional OTPs
469
+ * under TCPA/PECR/CASL/GDPR. Override if your jurisdiction or legal team
470
+ * requires affirmative consent capture for OTP channels.
471
+ */
472
+ recordOtpChannelConsent(_username: string, _channel: "email" | "sms", _target: string, _disclosure: string): Promise<void>;
473
+ }
474
+ //#endregion
49
475
  //#region src/auth.guard.d.ts
50
476
  /**
51
477
  * `GUARD`-priority interceptor factory that authenticates incoming requests.
@@ -76,7 +502,7 @@ declare function authGuardInterceptor(opts?: AuthOptions): TInterceptorDef;
76
502
  */
77
503
  declare function AuthGuarded(opts?: AuthOptions): ClassDecorator & MethodDecorator;
78
504
  //#endregion
79
- //#region ../../node_modules/.pnpm/@wooksjs+event-wf@0.7.12_@prostojs+logger@0.4.3_@wooksjs+event-core@0.7.12_@wooksjs+eve_83c9b5cff779134c35c379089be157ae/node_modules/@wooksjs/event-wf/dist/index.d.ts
505
+ //#region ../../node_modules/.pnpm/@wooksjs+event-wf@0.7.15_@prostojs+logger@0.4.3_@wooksjs+event-core@0.7.15_@wooksjs+eve_11afd4acf41dda7cd1e566c02621a2f8/node_modules/@wooksjs/event-wf/dist/index.d.ts
80
506
  interface WfFinishedResponse {
81
507
  type: 'redirect' | 'data';
82
508
  /** Redirect URL or response body */
@@ -210,15 +636,15 @@ declare function getAuthMate(): AuthMate;
210
636
  //#endregion
211
637
  //#region src/auth.controller.d.ts
212
638
  /** Workflows allowed by the bundled `/auth/trigger` endpoint. Subclasses override `triggerWf()` to extend. */
213
- declare const DEFAULT_AUTH_WORKFLOWS: readonly ["auth.login", "auth.recovery", "auth.invite"];
639
+ declare const DEFAULT_AUTH_WORKFLOWS: readonly ["auth/login/flow", "auth/recovery/flow", "auth/invite/start"];
214
640
  /**
215
641
  * Public REST endpoints for credential management. Four endpoints total:
216
642
  *
217
643
  * - `POST /auth/logout` — best-effort token revocation + cookie clear.
218
644
  * - `POST /auth/refresh` — rotate access/refresh tokens.
219
645
  * - `GET /auth/status` — return the current `AuthContext`.
220
- * - `POST /auth/trigger` — single workflow trigger covering `auth.login`,
221
- * `auth.recovery`, and `auth.invite`.
646
+ * - `POST /auth/trigger` — single workflow trigger covering `auth/login/flow`,
647
+ * `auth/recovery/flow`, and `auth/invite/start`.
222
648
  *
223
649
  * The historical `/auth/login` and `/auth/password` endpoints were dropped —
224
650
  * both flows go through the workflow trigger now (full MFA / SSO / etc.
@@ -230,11 +656,37 @@ declare const DEFAULT_AUTH_WORKFLOWS: readonly ["auth.login", "auth.recovery", "
230
656
  */
231
657
  declare class AuthController {
232
658
  protected readonly auth: AuthCredential;
233
- constructor(auth: AuthCredential);
659
+ protected readonly users?: UserService | undefined;
660
+ constructor(auth: AuthCredential, users?: UserService | undefined);
234
661
  logout(body: AuthLogoutBody | undefined): Promise<AuthOkResponse>;
235
662
  refresh(body: AuthRefreshBody | undefined): Promise<AuthLoginResponse>;
236
663
  status(): AuthContext$1;
237
664
  triggerWf(): void;
665
+ /**
666
+ * Side route mapping a redeemed-invite `uid` to the same idempotent
667
+ * envelope the `inviteIdempotentRedirect` workflow step renders. The SPA
668
+ * falls through to this when an invite magic-link is re-clicked after
669
+ * redemption: the wf state store has already evicted the finished state
670
+ * (returns 410) so the workflow can't re-enter `inviteCheckPendingInvitation`,
671
+ * but the user-id baked into the magic-link URL by `buildMagicLinkUrl(…, {
672
+ * userId })` lets the SPA resolve the "already accepted" condition itself.
673
+ *
674
+ * `@Public()` — invitees aren't signed in at this point.
675
+ *
676
+ * Defaults for `loginUrl` / `alreadyAcceptedRedirectUrl` mirror the bundled
677
+ * `InviteWorkflowOpts` defaults (`/login` / `/login`). Subclasses override
678
+ * `resolveInvitePostRedemption()` to read live workflow opts.
679
+ */
680
+ invitePostRedemption(uid: string | undefined): Promise<WfFinished>;
681
+ /**
682
+ * URLs used by `invitePostRedemption`. Defaults mirror
683
+ * `mergeInviteOpts({})` so subclasses that customize either of those
684
+ * options can override here to keep the side route in sync.
685
+ */
686
+ protected resolveInvitePostRedemption(): {
687
+ loginUrl: string;
688
+ alreadyAcceptedRedirectUrl: string;
689
+ };
238
690
  }
239
691
  //#endregion
240
692
  //#region src/wf-trigger/decorator.d.ts
@@ -271,6 +723,11 @@ declare const WfTrigger: (opts?: WfTriggerOpts) => ClassDecorator & MethodDecora
271
723
  * Uses `handleAsOutletRequest` (not `MoostWf.handleOutlet`) because the atscript
272
724
  * wrapper restores the `finished: true` marker that `<AsWfForm>` keys off — the
273
725
  * bare wooks request handler strips it during `useWfFinished()` unwrap.
726
+ *
727
+ * The trigger is a thin pass-through to `handleAsOutletRequest`: the new
728
+ * `@atscript/moost-wf` wire envelope is `{ wfs, input: { action?, formData? } }`,
729
+ * and the wf engine reads action + form data directly from `body.input`. No
730
+ * app-level bridging of `body.action` is needed.
274
731
  */
275
732
  declare class WfTriggerProvider {
276
733
  protected readonly wf: MoostWf;
@@ -298,7 +755,7 @@ interface AuditEvent {
298
755
  kind: string;
299
756
  /** Auth-scoped user identity (the `username` resolved by the workflow). */
300
757
  userId?: string;
301
- /** Workflow id that emitted the event (e.g. `auth.login`). */
758
+ /** Workflow id that emitted the event (e.g. `auth/login/flow`). */
302
759
  workflow?: string;
303
760
  /** Source IP (when the workflow could resolve one). */
304
761
  ip?: string;
@@ -313,7 +770,6 @@ interface AuditEmitter {
313
770
  //#endregion
314
771
  //#region src/workflows/login.workflow.options.d.ts
315
772
  type LoginRedirect = "referer" | "home" | false | null;
316
- type MfaTransport = "sms" | "email" | "totp";
317
773
  interface SsoProvider {
318
774
  id: string;
319
775
  label: string;
@@ -324,60 +780,11 @@ interface ConcurrencyLimitOptions {
324
780
  onLimit: "reject" | "kickPrompt";
325
781
  }
326
782
  interface LoginWorkflowOpts {
327
- alternateCredentials?: {
328
- forgotPassword?: boolean;
329
- signup?: boolean;
330
- magicLink?: boolean;
331
- magicLinkSkipsMfa?: boolean;
332
- magicLinkTtlMs?: number;
333
- ssoProviders?: SsoProvider[];
334
- recoveryUrl?: string;
335
- signupUrl?: string;
336
- embedRecovery?: boolean;
337
- };
338
- guards?: {
339
- emailVerifiedRequired?: boolean;
340
- passwordExpiry?: boolean;
341
- passwordInitial?: boolean;
342
- };
343
- enrollment?: {
344
- ensureEmail?: boolean;
345
- ensurePhone?: boolean;
346
- };
347
- mfa?: {
348
- enabled?: boolean;
349
- transports?: MfaTransport[];
350
- backupCodes?: boolean;
351
- enrollRequired?: boolean;
352
- pincodeTtlMs?: number;
353
- pincodeResendTimeoutMs?: number; /** Numeric length of the server-generated OTP for SMS/email pincodes. */
354
- pincodeLength?: number;
355
- };
356
783
  deviceTrust?: {
357
- enabled?: boolean;
358
- optIn?: boolean;
359
784
  cookieName?: string;
360
785
  ttlMs?: number;
361
- skipsMfa?: boolean;
362
786
  bindsTo?: "cookie" | "cookie+ip";
363
787
  };
364
- acceptance?: {
365
- termsVersion?: string;
366
- profileCompleteRequired?: boolean;
367
- consentMarketing?: boolean;
368
- };
369
- multiContext?: {
370
- tenantSelect?: boolean;
371
- personaSelect?: boolean;
372
- };
373
- sessionPolicy?: {
374
- concurrencyLimit?: ConcurrencyLimitOptions;
375
- };
376
- finalize?: {
377
- auditLogin?: boolean;
378
- notifyNewDevice?: boolean;
379
- redirect?: LoginRedirect;
380
- };
381
788
  /**
382
789
  * Replaceable form schemas. Each field defaults to the corresponding
383
790
  * `.as` form shipped under `@aooth/auth-moost/atscript/models`; supply a
@@ -388,7 +795,9 @@ interface LoginWorkflowOpts {
388
795
  askPhone?: TAtscriptAnnotatedType;
389
796
  backupCode?: TAtscriptAnnotatedType;
390
797
  concurrencyLimit?: TAtscriptAnnotatedType;
391
- consentMarketing?: TAtscriptAnnotatedType;
798
+ enrollAddress?: TAtscriptAnnotatedType;
799
+ enrollConfirm?: TAtscriptAnnotatedType;
800
+ enrollPickMethod?: TAtscriptAnnotatedType;
392
801
  loginCredentials?: TAtscriptAnnotatedType;
393
802
  mfaCode?: TAtscriptAnnotatedType;
394
803
  personaSelect?: TAtscriptAnnotatedType;
@@ -397,78 +806,28 @@ interface LoginWorkflowOpts {
397
806
  select2fa?: TAtscriptAnnotatedType;
398
807
  setPassword?: TAtscriptAnnotatedType;
399
808
  tenantSelect?: TAtscriptAnnotatedType;
400
- termsAccept?: TAtscriptAnnotatedType;
809
+ termsBump?: TAtscriptAnnotatedType;
401
810
  };
402
811
  }
403
812
  /**
404
813
  * Fully-resolved view used by the workflow at runtime — every nested group is
405
- * always populated by `mergeLoginOpts`, so schema conditions can read
406
- * `ctx.opts.<group>.<flag>` directly without optional chaining.
407
- *
408
- * Fields without sensible defaults (e.g. `termsVersion`, `concurrencyLimit`)
409
- * stay optional inside their group.
814
+ * always populated by `mergeLoginOpts`, so step bodies can read
815
+ * `this.opts.<group>.<flag>` directly without optional chaining.
410
816
  */
411
817
  interface ResolvedLoginWorkflowOpts {
412
- alternateCredentials: {
413
- forgotPassword: boolean;
414
- signup: boolean;
415
- magicLink: boolean;
416
- magicLinkSkipsMfa: boolean;
417
- magicLinkTtlMs: number;
418
- ssoProviders: SsoProvider[];
419
- recoveryUrl: string;
420
- signupUrl: string;
421
- embedRecovery: boolean;
422
- };
423
- guards: {
424
- emailVerifiedRequired: boolean;
425
- passwordExpiry: boolean;
426
- passwordInitial: boolean;
427
- };
428
- enrollment: {
429
- ensureEmail: boolean;
430
- ensurePhone: boolean;
431
- };
432
- mfa: {
433
- enabled: boolean;
434
- transports: MfaTransport[];
435
- backupCodes: boolean;
436
- enrollRequired: boolean;
437
- pincodeTtlMs: number;
438
- pincodeResendTimeoutMs: number;
439
- pincodeLength: number;
440
- };
441
818
  deviceTrust: {
442
- enabled: boolean;
443
- optIn: boolean;
444
819
  cookieName: string;
445
820
  ttlMs: number;
446
- skipsMfa: boolean;
447
821
  bindsTo: "cookie" | "cookie+ip";
448
822
  };
449
- acceptance: {
450
- termsVersion?: string;
451
- profileCompleteRequired: boolean;
452
- consentMarketing: boolean;
453
- };
454
- multiContext: {
455
- tenantSelect: boolean;
456
- personaSelect: boolean;
457
- };
458
- sessionPolicy: {
459
- concurrencyLimit?: ConcurrencyLimitOptions;
460
- };
461
- finalize: {
462
- auditLogin: boolean;
463
- notifyNewDevice: boolean;
464
- redirect: LoginRedirect;
465
- };
466
823
  forms: {
467
824
  askEmail: TAtscriptAnnotatedType;
468
825
  askPhone: TAtscriptAnnotatedType;
469
826
  backupCode: TAtscriptAnnotatedType;
470
827
  concurrencyLimit: TAtscriptAnnotatedType;
471
- consentMarketing: TAtscriptAnnotatedType;
828
+ enrollAddress: TAtscriptAnnotatedType;
829
+ enrollConfirm: TAtscriptAnnotatedType;
830
+ enrollPickMethod: TAtscriptAnnotatedType;
472
831
  loginCredentials: TAtscriptAnnotatedType;
473
832
  mfaCode: TAtscriptAnnotatedType;
474
833
  personaSelect: TAtscriptAnnotatedType;
@@ -477,7 +836,7 @@ interface ResolvedLoginWorkflowOpts {
477
836
  select2fa: TAtscriptAnnotatedType;
478
837
  setPassword: TAtscriptAnnotatedType;
479
838
  tenantSelect: TAtscriptAnnotatedType;
480
- termsAccept: TAtscriptAnnotatedType;
839
+ termsBump: TAtscriptAnnotatedType;
481
840
  };
482
841
  }
483
842
  /**
@@ -489,33 +848,168 @@ declare function mergeLoginOpts(opts?: LoginWorkflowOpts): ResolvedLoginWorkflow
489
848
  //#endregion
490
849
  //#region src/workflows/login.workflow.d.ts
491
850
  interface LoginWfCtx {
492
- opts?: ResolvedLoginWorkflowOpts;
851
+ /**
852
+ * Whether the user must complete profile fields BEFORE token issuance.
853
+ * Populated by `prepare-profile` from `resolveProfile(ctx).required`.
854
+ * Default-false matches the prior behavior (most consumers don't gate logins
855
+ * on profile completion). Read by the `profile-complete` schema condition.
856
+ */
857
+ profileCompleteRequired?: boolean;
858
+ alternateCredentials?: {
859
+ forgotPassword: boolean;
860
+ signup: boolean;
861
+ magicLink: boolean;
862
+ magicLinkSkipsMfa: boolean;
863
+ ssoProviders: SsoProvider[];
864
+ recoveryUrl: string;
865
+ signupUrl: string;
866
+ embedRecovery: boolean;
867
+ };
868
+ deviceTrust?: {
869
+ enabled: boolean;
870
+ optIn: boolean;
871
+ skipsMfa: boolean;
872
+ };
873
+ enrollment?: {
874
+ ensureEmail: boolean;
875
+ ensurePhone: boolean;
876
+ };
877
+ finalize?: {
878
+ auditLogin: boolean;
879
+ notifyNewDevice: boolean;
880
+ redirect: LoginRedirect;
881
+ };
882
+ guards?: {
883
+ passwordInitial: boolean;
884
+ passwordExpiry: boolean;
885
+ emailVerifiedRequired: boolean;
886
+ };
887
+ mfaConfig?: {
888
+ backupCodes: boolean;
889
+ };
890
+ multiContext?: {
891
+ tenantSelect: boolean;
892
+ personaSelect: boolean;
893
+ };
894
+ sessionPolicy?: {
895
+ concurrencyLimit?: ConcurrencyLimitOptions;
896
+ };
493
897
  username?: string;
494
898
  /** Legacy alias for `pwReset`; kept until tests migrate. */
495
899
  mfaRequired?: boolean;
496
900
  isPasswordInitial?: boolean;
497
901
  usedMagicLink?: boolean;
902
+ /**
903
+ * 3-state MFA policy:
904
+ * - `'required'` — MFA enforced; users with 0 methods MUST enroll (no skip).
905
+ * - `'optional'` — MFA prompted; users with 0 methods see an enrollment
906
+ * form that offers a `skip` action (in-flight opt-out).
907
+ * - `'disabled'` — MFA loops never fire; Phase 4 is skipped entirely.
908
+ */
909
+ mfaMode?: "required" | "optional" | "disabled";
910
+ availableMfaTransports?: MfaTransport[];
911
+ /** Pre-selected MFA transport (e.g. existing-user default, single-transport auto-pick). */
912
+ currentMfa?: MfaTransport;
498
913
  email?: string;
499
914
  emailConfirmed?: boolean;
500
915
  phone?: string;
501
916
  phoneConfirmed?: boolean;
917
+ /**
918
+ * Disclosure text rendered beneath the channel input field on
919
+ * `AskEmailForm` / `AskPhoneForm` at ask-time (BEFORE the user submits
920
+ * their email/phone — typing + submitting constitutes implied consent).
921
+ * Forwarded to `consentStore.recordOtpChannelConsent` at `verify/:channel`
922
+ * AFTER the channel is confirmed as an MFA method, so the persisted record
923
+ * pins the literal copy the user actually saw. The disclosure text itself
924
+ * is GENERIC per-channel (no target templated in) — the verified target
925
+ * is captured as a separate audit-record field.
926
+ */
927
+ otpDisclosure?: string;
502
928
  mfaEnrolledMethods?: MfaSummary[];
503
929
  mfaMethod?: "sms" | "email" | "totp";
504
930
  mfaSaveAsDefault?: boolean;
505
931
  ignoreMfaDefault?: boolean;
506
932
  mfaChecked?: boolean;
933
+ /**
934
+ * Set true the first time the user picks `useBackupCode` on the MFA step,
935
+ * so the workflow remembers to route the subsequent `BackupCodeForm`
936
+ * submission (which carries no `action`) through `handleBackupCode` instead
937
+ * of falling through to `verifyMfa` / pincode-verify.
938
+ */
939
+ usingBackupCode?: boolean;
507
940
  /** Counter incremented by the `risk-step-up` step so MFA reruns for the extra factor. */
508
941
  mfaRunsRemaining?: number;
942
+ /** Mirror of `mfaEnrolledMethods.length`. Passed to client forms via `@wf.context.pass` so action buttons (`useDifferentMethod`) can hide when only one method exists. */
943
+ mfaMethodCount?: number;
944
+ /** Mirror of `opts.mfa.backupCodes`. Passed to client forms so `useBackupCode` can hide when backup codes are disabled. */
945
+ mfaBackupCodes?: boolean;
946
+ altForgotPassword?: boolean;
947
+ altSignup?: boolean;
948
+ altMagicLink?: boolean;
949
+ enrollMethod?: MfaTransport;
950
+ enrollAddress?: string;
951
+ /** TOTP secret in flight (passed to UI via `@wf.context.pass` for QR rendering). */
952
+ enrollSecret?: string;
953
+ /** Provisioning URI for TOTP QR rendering. */
954
+ enrollUri?: string;
955
+ /** Mirror of `ctx.availableMfaTransports`, surfaced to `EnrollPickMethodForm` via `@wf.context.pass`. */
956
+ enrollAvailableTransports?: MfaTransport[];
957
+ /**
958
+ * Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
959
+ * `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
960
+ * hide unless mode is `'optional'`.
961
+ */
962
+ enrollMode?: "required" | "optional";
963
+ /** Set true by `enrollConfirmPhase` (or `enrollPickPhase`/`enrollAddressPhase` on `skip` in `'optional'` mode); mirrored to `mfaChecked` via `buildLoginEnrollDeps` `onComplete`. */
964
+ enrollDone?: boolean;
965
+ /** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
966
+ enrollPincodeCooldown?: number;
509
967
  pin?: string;
510
968
  pinExpire?: number;
511
969
  pinTimeout?: number;
512
970
  pinSentTo?: string;
971
+ /**
972
+ * Per-method "next-allowed-send-at" timestamp. Written by
973
+ * `pincode-send-login` after each send and consulted by `select2fa` to
974
+ * reject re-picking a method while it's still in cooldown. Closes the
975
+ * `useDifferentMethod → same method → fresh SMS` abuse loop: without this
976
+ * an attacker (or an impatient user) can spam SMS/email by alternating
977
+ * methods. Persists across `delete ctx.pin` (resend/useDifferentMethod)
978
+ * so the throttle survives a method switch.
979
+ */
980
+ pincodeCooldowns?: {
981
+ sms?: number;
982
+ email?: number;
983
+ };
513
984
  deviceTrustToken?: string;
514
985
  /** Set true at MFA gate when no trust cookie matched → trigger `notify-new-device`. */
515
986
  newDevice?: boolean;
516
987
  /** Captured from the OTP/pincode form when `opts.deviceTrust.optIn`. */
517
988
  rememberDevice?: boolean;
518
- termsAcceptedVersion?: string;
989
+ /** Mirror of `opts.deviceTrust.optIn`. Passed to `PincodeForm` so the `rememberDevice` checkbox can hide when the consumer's device-trust is off-by-default (no user choice to make). */
990
+ deviceTrustOptIn?: boolean;
991
+ /**
992
+ * Descriptors for the customer-defined general consents (terms, marketing,
993
+ * jurisdiction, ...) the user still needs to accept. Populated once by
994
+ * `prepare-consents` after username-bind. Phase 5 will migrate carrier
995
+ * forms to consume this array; Phase 4 populates transport only.
996
+ */
997
+ pendingConsents?: ConsentDescriptor[];
998
+ /**
999
+ * Subset of `pendingConsents[].id` the user ticked on the carrier form —
1000
+ * set by `processInlineConsent` after silent-dropping unknown ids.
1001
+ * Consumed by `persist-consents` to compute `accepted: boolean` per
1002
+ * pending descriptor.
1003
+ */
1004
+ acceptedConsentIds?: string[];
1005
+ /**
1006
+ * Wall-clock ms at the moment `processInlineConsent` resolved the
1007
+ * `consents` carrier-form submission (NOT at write-time — captured BEFORE
1008
+ * the batched `persist-consents` step so a paused-workflow resume gap
1009
+ * doesn't drift the timestamp). Also the schema-gate for the
1010
+ * `persist-consents` step — set ⇒ a carrier form has collected consents.
1011
+ */
1012
+ consentsDecidedAt?: number;
519
1013
  profileMissingFields?: string[];
520
1014
  availableTenants?: Array<{
521
1015
  id: string;
@@ -530,9 +1024,15 @@ interface LoginWfCtx {
530
1024
  riskStepUpReason?: string;
531
1025
  activeSessions?: number;
532
1026
  passwordChanged?: boolean;
533
- termsAcceptedDone?: boolean;
534
1027
  profileApplied?: boolean;
535
- consentApplied?: boolean;
1028
+ /**
1029
+ * Set true by `persist-consents` after the batched `consentStore.save`
1030
+ * call fires (or after the step short-circuits with no pending consents).
1031
+ * Gates `processInlineConsent` from re-staging on subsequent carrier
1032
+ * forms in the same workflow run, and the `persist-consents` schema
1033
+ * condition from re-firing.
1034
+ */
1035
+ consentsPersisted?: boolean;
536
1036
  tokensIssued?: boolean;
537
1037
  redirectUrl?: string;
538
1038
  /**
@@ -544,6 +1044,32 @@ interface LoginWfCtx {
544
1044
  /** Tracks whether `risk-step-up` has already been evaluated this iteration. */
545
1045
  riskStepUpEvaluated?: boolean;
546
1046
  }
1047
+ /**
1048
+ * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1049
+ * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1050
+ * @Step methods populate — one entry per resolver. Library users typically
1051
+ * accept a payload of this shape on their `LoginWorkflow` subclass ctor /
1052
+ * test harness and have each `resolveXxx` return its matching key (falling
1053
+ * back to `super.resolveXxx(ctx)` for unset groups).
1054
+ */
1055
+ interface LoginPolicyOverrides {
1056
+ /**
1057
+ * Override the profile-completion policy (`{ required: boolean }`) — the
1058
+ * boolean is mirrored onto `ctx.profileCompleteRequired` by `prepare-profile`
1059
+ * and read by the `profile-complete` schema condition.
1060
+ */
1061
+ profile?: {
1062
+ required: boolean;
1063
+ };
1064
+ alternateCredentials?: NonNullable<LoginWfCtx["alternateCredentials"]>;
1065
+ deviceTrust?: NonNullable<LoginWfCtx["deviceTrust"]>;
1066
+ enrollment?: NonNullable<LoginWfCtx["enrollment"]>;
1067
+ finalize?: NonNullable<LoginWfCtx["finalize"]>;
1068
+ guards?: NonNullable<LoginWfCtx["guards"]>;
1069
+ mfaConfig?: NonNullable<LoginWfCtx["mfaConfig"]>;
1070
+ multiContext?: NonNullable<LoginWfCtx["multiContext"]>;
1071
+ sessionPolicy?: NonNullable<LoginWfCtx["sessionPolicy"]>;
1072
+ }
547
1073
  interface MfaSummary {
548
1074
  kind: "sms" | "email" | "totp";
549
1075
  /** Underlying `MfaMethod.name` so the workflow can call into UserService. */
@@ -583,14 +1109,16 @@ interface DeliverSms {
583
1109
  userId?: string;
584
1110
  }
585
1111
  type DeliverPayload = DeliverEmail | DeliverSms;
586
- declare class LoginWorkflow {
1112
+ declare class LoginWorkflow extends AuthWorkflowBase {
587
1113
  protected readonly opts: ResolvedLoginWorkflowOpts;
588
1114
  protected readonly users: UserService;
589
1115
  protected readonly auth: AuthCredential;
590
- constructor(opts: LoginWorkflowOpts, users: UserService, auth: AuthCredential);
1116
+ protected readonly authOpts: AuthOpts;
1117
+ protected readonly consentStore: ConsentStore;
1118
+ constructor(opts: LoginWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
591
1119
  /**
592
1120
  * Dispatch an email or SMS event. Default throws — consumers MUST override
593
- * if any feature that emits is enabled (MFA pincode, ensureEmail/Phone OTP,
1121
+ * if any feature that emits is enabled (MFA pincode, ask/verify channel OTP,
594
1122
  * notifyNewDevice). The throw surfaces at the HTTP layer as 500 on the
595
1123
  * first event that triggers a send, which is the fail-loud signal.
596
1124
  */
@@ -630,62 +1158,197 @@ declare class LoginWorkflow {
630
1158
  * `storeTrustedDevice` against Redis but keep this default.
631
1159
  */
632
1160
  protected issueTrustedDevice(userId: string, ip: string | undefined, ttlMs: number): Promise<TrustedDeviceRecord>;
1161
+ /**
1162
+ * Resolve the profile-completion policy. Returns `{ required: boolean }` —
1163
+ * whether the user must complete profile fields (e.g. `firstName` /
1164
+ * `lastName`) BEFORE token issuance. Override per-tenant or per-user to
1165
+ * gate logins on missing profile fields; the boolean is mirrored onto
1166
+ * `ctx.profileCompleteRequired` by `prepare-profile` and read by the
1167
+ * `profile-complete` schema condition (which AND-s the gate with
1168
+ * `ctx.profileMissingFields.length > 0` so the step only fires when the
1169
+ * consumer has surfaced fields to collect — typically populated by a
1170
+ * `credentials` override that hydrates `ctx.profileMissingFields` from
1171
+ * the user row).
1172
+ *
1173
+ * Default-false matches the prior behavior — most consumers don't gate
1174
+ * logins on profile completion. Async-friendly via the union return type —
1175
+ * sync defaults stay sync (engine fast path).
1176
+ */
1177
+ protected resolveProfile(_ctx: LoginWfCtx): {
1178
+ required: boolean;
1179
+ } | Promise<{
1180
+ required: boolean;
1181
+ }>;
1182
+ /**
1183
+ * Resolve the alternate-credentials policy (forgot-password / signup /
1184
+ * magic-link / SSO providers + their URLs). Override to enable/disable per
1185
+ * tenant. Sync default; async overrides supported.
1186
+ */
1187
+ protected resolveAlternateCredentials(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["alternateCredentials"]> | Promise<NonNullable<LoginWfCtx["alternateCredentials"]>>;
1188
+ /**
1189
+ * Resolve the device-trust policy (enabled / opt-in / skipsMfa). Infrastructure
1190
+ * (cookieName / ttlMs / bindsTo) still lives on `this.opts.deviceTrust` since
1191
+ * those are app-wide constants, not per-request policy. Sync/async friendly.
1192
+ */
1193
+ protected resolveDeviceTrust(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["deviceTrust"]> | Promise<NonNullable<LoginWfCtx["deviceTrust"]>>;
1194
+ /**
1195
+ * Resolve the channel-enrollment policy (ensureEmail / ensurePhone gates).
1196
+ * Override to force email/phone capture per user segment. Sync/async friendly.
1197
+ */
1198
+ protected resolveEnrollment(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["enrollment"]> | Promise<NonNullable<LoginWfCtx["enrollment"]>>;
1199
+ /**
1200
+ * Resolve the finalize policy (audit emission / new-device notification /
1201
+ * redirect target). Override to drive per-tenant audit-log routing or
1202
+ * per-app redirect targets. Sync/async friendly.
1203
+ */
1204
+ protected resolveFinalize(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["finalize"]> | Promise<NonNullable<LoginWfCtx["finalize"]>>;
1205
+ /**
1206
+ * Resolve the guards policy (passwordInitial / passwordExpiry /
1207
+ * emailVerifiedRequired). Override per-tenant to tighten or loosen the
1208
+ * post-credentials gates. Sync/async friendly.
1209
+ */
1210
+ protected resolveGuards(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["guards"]> | Promise<NonNullable<LoginWfCtx["guards"]>>;
1211
+ /**
1212
+ * Resolve the MFA config (currently only backup-codes availability — pincode
1213
+ * timings stay on `this.opts.mfa` as infrastructure). Override to enable or
1214
+ * disable backup-code redemption per tenant. Sync/async friendly.
1215
+ */
1216
+ protected resolveMfaConfig(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["mfaConfig"]> | Promise<NonNullable<LoginWfCtx["mfaConfig"]>>;
1217
+ /**
1218
+ * Resolve the multi-context policy (tenantSelect / personaSelect prompts).
1219
+ * Override to require a tenant/persona pick when the user has more than one.
1220
+ * Sync/async friendly.
1221
+ */
1222
+ protected resolveMultiContext(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["multiContext"]> | Promise<NonNullable<LoginWfCtx["multiContext"]>>;
1223
+ /**
1224
+ * Resolve the disclosure text rendered beneath the channel input field on
1225
+ * `AskEmailForm` / `AskPhoneForm` at ask-time — BEFORE the user submits
1226
+ * their email/phone. Default returns a TCPA / PECR / CASL / GDPR-safe
1227
+ * English paragraph that is GENERIC per channel (no target templated in,
1228
+ * since the user hasn't submitted it yet). Override per-tenant or per-
1229
+ * locale to swap copy (e.g. i18n catalog lookup). The resolved string is
1230
+ * mirrored onto `ctx.otpDisclosure`, transported to the SPA via
1231
+ * `@wf.context.pass`, and forwarded to
1232
+ * `consentStore.recordOtpChannelConsent` at `verify/:channel` AFTER the
1233
+ * pincode validates AND the channel is confirmed as an MFA method — the
1234
+ * persisted audit record pins BOTH the literal disclosure copy the user
1235
+ * saw AND the verified target as a separate field.
1236
+ *
1237
+ * Disclosure-only is sufficient for transactional security codes by default;
1238
+ * customers wanting affirmative consent capture override
1239
+ * `ConsentStore.recordOtpChannelConsent` instead. Sync/async friendly.
1240
+ */
1241
+ protected resolveOtpDisclosure(_ctx: LoginWfCtx, channel: "email" | "phone"): string | Promise<string>;
1242
+ /**
1243
+ * Resolve the session policy (concurrency limit). Override to enforce a
1244
+ * per-tenant or per-user max-concurrent-sessions cap with reject / kickPrompt
1245
+ * behaviour. Sync/async friendly.
1246
+ */
1247
+ protected resolveSessionPolicy(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["sessionPolicy"]> | Promise<NonNullable<LoginWfCtx["sessionPolicy"]>>;
1248
+ /**
1249
+ * Call `resolveProfile(ctx)` and mirror `result.required` onto
1250
+ * `ctx.profileCompleteRequired`. Promise-branched body preserves the engine's
1251
+ * sync fast path: a sync `resolveProfile` override skips the microtask
1252
+ * allocation, while an `async` override is awaited via `.then` before the
1253
+ * `profile-complete` schema condition reads the boolean. The resolved POJO
1254
+ * is intentionally NOT stashed on ctx as a group — `profileCompleteRequired`
1255
+ * is the only field, so a top-level boolean keeps the ctx shape flat.
1256
+ */
1257
+ prepareProfile(ctx: LoginWfCtx): undefined | Promise<undefined>;
1258
+ prepareAlternateCredentials(ctx: LoginWfCtx): undefined | Promise<undefined>;
1259
+ prepareDeviceTrust(ctx: LoginWfCtx): undefined | Promise<undefined>;
1260
+ prepareEnrollment(ctx: LoginWfCtx): undefined | Promise<undefined>;
1261
+ prepareFinalize(ctx: LoginWfCtx): undefined | Promise<undefined>;
1262
+ prepareGuards(ctx: LoginWfCtx): undefined | Promise<undefined>;
1263
+ prepareMfaConfig(ctx: LoginWfCtx): undefined | Promise<undefined>;
1264
+ prepareMultiContext(ctx: LoginWfCtx): undefined | Promise<undefined>;
1265
+ prepareSessionPolicy(ctx: LoginWfCtx): undefined | Promise<undefined>;
1266
+ /**
1267
+ * Populate `ctx.pendingConsents` with the customer-defined general-consent
1268
+ * descriptors (terms, marketing, jurisdiction, ...) the user still needs to
1269
+ * accept. Phase 4 transport only — nothing reads `ctx.pendingConsents` yet;
1270
+ * Phase 5 will migrate carrier forms (`SetPasswordForm`, `ProfileCompleteForm`,
1271
+ * ...) from the `WithInlineConsentForm` static-checkbox mixin onto this
1272
+ * dynamic array.
1273
+ *
1274
+ * Username MUST be bound before we fetch consents — customer impls key
1275
+ * history by user, and pre-bind there's no identity to dedup against. The
1276
+ * schema places this step AFTER the `!ctx.username` break gate, so the
1277
+ * `if (!ctx.username)` guard is belt-and-brace for future refactors that
1278
+ * might re-order the schema.
1279
+ */
1280
+ prepareConsents(ctx: LoginWfCtx): undefined | Promise<undefined>;
633
1281
  flow(): void;
634
- init(ctx: LoginWfCtx): undefined;
635
1282
  /**
636
- * Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
637
- * conditions to read. Default: drop the `forms` group (atscript form classes
638
- * are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
639
- * Step bodies still consult the form classes via `this.opts.forms.*`.
1283
+ * First step of the workflow; remains as a no-op override hook for
1284
+ * consumers (e.g. seeding pre-flight ctx fields, capturing request metadata).
1285
+ * The pre-PR policy-pojo-on-ctx stash was dropped policy now lives on
1286
+ * `ctx.<group>` populated by the dedicated `prepare-<group>` steps.
640
1287
  *
641
- * Consumers who put non-JSON values on `opts` (e.g. by extending the type)
642
- * can override this to strip them.
1288
+ * Return type is `undefined | Promise<undefined>` so consumers can override
1289
+ * with `async init(...)` without the default fast-path paying a Promise
1290
+ * allocation (the wf engine awaits only when the return value is a Promise).
643
1291
  */
644
- protected snapshotOpts(opts: ResolvedLoginWorkflowOpts): ResolvedLoginWorkflowOpts;
645
- credentials(input: {
646
- username?: string;
647
- password?: string;
648
- action?: string;
649
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1292
+ init(_ctx: LoginWfCtx): undefined | Promise<undefined>;
1293
+ /**
1294
+ * Prepare MFA setup: writes `ctx.mfaMode`, `ctx.availableMfaTransports`, and
1295
+ * (when the user is resolvable) pre-selects `ctx.currentMfa` from the
1296
+ * existing-user `defaultMethod` or the single-available-transport auto-pick.
1297
+ * Override to compute any of the three from tenant policy / user attrs /
1298
+ * request context. Return type allows a sync override (skip the promise
1299
+ * round-trip) when no async work is needed — the default body is async only
1300
+ * because of the `users.getUser` lookup for `currentMfa`.
1301
+ */
1302
+ prepareMfaSetup(ctx: LoginWfCtx): undefined | Promise<undefined>;
1303
+ credentials(ctx: LoginWfCtx): Promise<unknown>;
650
1304
  private handleCredentialsAlt;
651
1305
  /**
652
- * Builds the redirect URL the `forgotPassword` alt-action navigates to.
1306
+ * Resolves the redirect URL the `forgotPassword` alt-action navigates to.
653
1307
  * Receives whatever the user typed into the username field so the recovery
654
1308
  * page can pre-fill it. Default:
655
1309
  * `${alternateCredentials.recoveryUrl}?username=${encodeURIComponent(username ?? '')}`.
1310
+ * Sync return type only — the caller (`credentials` step's alt-action
1311
+ * handler) uses the URL inline; consumers needing async URL construction
1312
+ * should override the `credentials` @Step instead. The resolved
1313
+ * `alternateCredentials` policy is supplied by the caller so the base impl
1314
+ * doesn't have to re-call `resolveAlternateCredentials`.
656
1315
  */
657
- protected buildRecoveryUrl(username?: string): string;
658
- magicLinkRequest(): never;
659
- magicLinkSend(): never;
660
- magicLinkVerified(): never;
661
- passkey(): never;
662
- ssoCallback(): never;
663
- ensureEmail(input: {
664
- email?: string;
665
- code?: string;
666
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
667
- ensurePhone(input: {
668
- phone?: string;
669
- code?: string;
670
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1316
+ protected resolveRecoveryUrl(username: string | undefined, alt: NonNullable<LoginWfCtx["alternateCredentials"]>): string;
1317
+ magicLinkRequest(): void | Promise<void>;
1318
+ magicLinkSend(): void | Promise<void>;
1319
+ magicLinkVerified(): void | Promise<void>;
1320
+ passkey(): void | Promise<void>;
1321
+ ssoCallback(): void | Promise<void>;
1322
+ ask(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
1323
+ verify(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
671
1324
  checkTrustedDevice(ctx: LoginWfCtx): Promise<undefined>;
672
- prepareMfaOptions(ctx: LoginWfCtx): Promise<undefined>;
673
- select2fa(input: {
674
- methodName?: string;
675
- saveAsDefault?: boolean;
676
- action?: string;
677
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1325
+ /**
1326
+ * Load + summarise the user's enrolled MFA methods (filtered against
1327
+ * `ctx.availableMfaTransports`) and mirror the form-gating flags
1328
+ * (`mfaMethodCount`, `mfaBackupCodes`, `deviceTrustOptIn`) onto ctx. Pure
1329
+ * data-load — no selection decision. Split out of the old
1330
+ * `prepare-mfa-options` step so consumers can override the load/summary
1331
+ * shape (custom MFA inventory source) without copying the selection
1332
+ * heuristics in `selectMfaMethod`.
1333
+ */
1334
+ loadEnrolledMfaMethods(ctx: LoginWfCtx): Promise<undefined>;
1335
+ /**
1336
+ * Pick which MFA method to use from the already-loaded
1337
+ * `ctx.mfaEnrolledMethods` summary. Decision-only — no IO. Honors
1338
+ * `ctx.currentMfa` (pre-selected by `prepareMfaSetup` from the user's
1339
+ * `defaultMethod` or single-transport auto-pick), auto-picks when only one
1340
+ * method is enrolled, falls back to the `isDefault` method. All paths are
1341
+ * gated on `!ctx.ignoreMfaDefault` so the `useDifferentMethod` re-pick flow
1342
+ * (which sets the flag) skips straight to the `select2fa` picker. Split out
1343
+ * of the old `prepare-mfa-options` step so consumers can override selection
1344
+ * heuristics (e.g. risk-based per-tenant defaults) without re-implementing
1345
+ * the load/summary.
1346
+ */
1347
+ selectMfaMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
1348
+ select2fa(ctx: LoginWfCtx): Promise<unknown>;
678
1349
  pincodeSendLogin(ctx: LoginWfCtx): Promise<undefined>;
679
- pincodeCheckLogin(input: {
680
- code?: string;
681
- action?: string;
682
- rememberDevice?: boolean;
683
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
684
- mfaTotp(input: {
685
- code?: string;
686
- action?: string;
687
- rememberDevice?: boolean;
688
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1350
+ pincodeCheckLogin(ctx: LoginWfCtx): Promise<unknown>;
1351
+ mfaTotp(ctx: LoginWfCtx): Promise<unknown>;
689
1352
  /**
690
1353
  * Backup-code alt-action handler shared by `select2fa`, `pincode-check-login`,
691
1354
  * and `mfa-totp`. Validates against `BackupCodeForm` (alphanumeric +
@@ -693,37 +1356,60 @@ declare class LoginWorkflow {
693
1356
  * produced by `UserService.generateBackupCodes`).
694
1357
  */
695
1358
  private handleBackupCode;
696
- mfaEnrollRequired(): never;
1359
+ /**
1360
+ * Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
1361
+ * transport, otherwise pauses for the picker form. When TOTP is picked, the
1362
+ * secret is provisioned in the same step body (see `enrollPickPhase`).
1363
+ * Sync-friendly return: the auto-pick branch and the picker-form branch are
1364
+ * both synchronous; only the TOTP-provisioning tail is async.
1365
+ */
1366
+ loginEnrollPickMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
1367
+ /**
1368
+ * Forced MFA enrollment — Phase 2 (collect sms/email address + send
1369
+ * pincode). Gated out for totp by the schema condition.
1370
+ */
1371
+ loginEnrollAddress(ctx: LoginWfCtx): undefined | Promise<undefined>;
1372
+ /**
1373
+ * Forced MFA enrollment — Phase 3 (verify code + activate method). Fires
1374
+ * `onComplete` to bridge `enrollDone` → `mfaChecked` so login's outer MFA
1375
+ * while-loop (gated on `!mfaChecked`) exits.
1376
+ */
1377
+ loginEnrollConfirm(ctx: LoginWfCtx): undefined | Promise<undefined>;
1378
+ /**
1379
+ * Build the `MfaEnrollDeps` payload shared by all three login enrollment
1380
+ * step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
1381
+ * `EnrollPickMethodForm` can hide the `skip` action unless mode is
1382
+ * `'optional'`) and supplies `onComplete` to mirror `enrollDone` →
1383
+ * `mfaChecked` for login's loop-exit signal.
1384
+ */
1385
+ private buildLoginEnrollDeps;
697
1386
  deviceTrust(ctx: LoginWfCtx): Promise<undefined>;
698
- preparePasswordRules(ctx: LoginWfCtx): undefined;
699
- createPasswordForm(input: {
700
- newPassword?: string;
701
- confirmPassword?: string;
702
- action?: string;
703
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
704
- termsAccept(input: {
705
- acceptedVersion?: string;
706
- accepted?: boolean;
707
- action?: string;
708
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
709
- profileComplete(input: Record<string, unknown> | undefined, ctx: LoginWfCtx): Promise<unknown>;
1387
+ preparePasswordRules(ctx: LoginWfCtx): undefined | Promise<undefined>;
1388
+ createPasswordForm(ctx: LoginWfCtx): Promise<unknown>;
1389
+ profileComplete(ctx: LoginWfCtx): Promise<unknown>;
710
1390
  /**
711
1391
  * Persists the profile-complete payload onto the user record. Default:
712
1392
  * no-op (the workflow records the form was submitted but writes nothing).
713
1393
  * Consumers override to write into their user store.
714
1394
  */
715
1395
  protected applyProfile(_username: string, _payload: Record<string, unknown>): Promise<void>;
716
- consentMarketing(input: {
717
- optIn?: boolean;
718
- action?: string;
719
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
720
1396
  /**
721
- * Persists the marketing consent decision. Default: no-op.
1397
+ * Standalone terms re-acceptance prompt for returning users whose accepted
1398
+ * terms version is stale — the consumer's `ConsentStore.getPendingConsents`
1399
+ * returned a non-empty descriptor list (typically a bumped terms version)
1400
+ * and no onboarding carrier form (ask-email/ask-phone/set-password/
1401
+ * profile-complete) ran to capture them via the dynamic `consents: string[]`
1402
+ * carrier field. The body delegates to `processInlineConsent`, which
1403
+ * handles validation + ctx writes identically to the inline path.
722
1404
  */
723
- protected applyConsentMarketing(_username: string, _optIn: boolean): Promise<void>;
724
- tenantSelect(input: {
725
- tenantId?: string;
726
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1405
+ termsBumpPrompt(ctx: LoginWfCtx): undefined;
1406
+ /**
1407
+ * Batched consent persistence — delegates to
1408
+ * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
1409
+ * audit-friendly-default / idempotency / silent-drop contract.
1410
+ */
1411
+ persistConsentsStep(ctx: LoginWfCtx): Promise<undefined>;
1412
+ tenantSelect(ctx: LoginWfCtx): Promise<unknown>;
727
1413
  /**
728
1414
  * Resolves the user's available tenants. Default: empty array. Consumers
729
1415
  * who enable `multiContext.tenantSelect` must override this to return the
@@ -733,9 +1419,7 @@ declare class LoginWorkflow {
733
1419
  id: string;
734
1420
  name: string;
735
1421
  }>>;
736
- personaSelect(input: {
737
- personaId?: string;
738
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1422
+ personaSelect(ctx: LoginWfCtx): Promise<unknown>;
739
1423
  /**
740
1424
  * Resolves the user's available personas. Default: empty array. Consumers
741
1425
  * who enable `multiContext.personaSelect` must override this.
@@ -744,9 +1428,15 @@ declare class LoginWorkflow {
744
1428
  id: string;
745
1429
  label: string;
746
1430
  }>>;
747
- concurrencyLimit(input: {
748
- action?: string;
749
- } | undefined, ctx: LoginWfCtx): Promise<unknown>;
1431
+ loadActiveSessionsStep(ctx: LoginWfCtx): Promise<undefined>;
1432
+ /**
1433
+ * Return the number of active (non-revoked, non-expired) sessions for the
1434
+ * user — consulted by `concurrency-limit` to decide whether the kickPrompt
1435
+ * branch fires. Default returns `0` (no enforcement). Override with a real
1436
+ * count from your credential store or session table.
1437
+ */
1438
+ protected loadActiveSessions(_username: string): Promise<number>;
1439
+ concurrencyLimit(ctx: LoginWfCtx): Promise<unknown>;
750
1440
  /**
751
1441
  * Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
752
1442
  * Default: no-op. Consumers override to revoke sessions in their auth store.
@@ -754,18 +1444,19 @@ declare class LoginWorkflow {
754
1444
  protected logoutOtherSessions(_username: string): Promise<void>;
755
1445
  riskStepUp(ctx: LoginWfCtx): Promise<undefined>;
756
1446
  /**
757
- * Risk-step-up assessor. Default: never requires an extra factor. Consumers
758
- * override to inspect ctx (IP, geo, time since last login, etc.) and return
759
- * `{require: true, reason: '…'}` to force an additional MFA round.
1447
+ * Resolves whether to require an additional MFA round (risk step-up).
1448
+ * Default: never requires an extra factor. Consumers override to inspect ctx
1449
+ * (IP, geo, time since last login, etc.) and return `{require: true, reason: '…'}`
1450
+ * to force an additional MFA round.
760
1451
  */
761
- protected assessRiskStepUp(_ctx: LoginWfCtx): Promise<{
1452
+ protected resolveRiskStepUp(_ctx: LoginWfCtx): Promise<{
762
1453
  require: boolean;
763
1454
  reason?: string;
764
1455
  }>;
765
1456
  issue(ctx: LoginWfCtx): Promise<void>;
766
1457
  auditLogin(ctx: LoginWfCtx): Promise<undefined>;
767
1458
  notifyNewDevice(ctx: LoginWfCtx): Promise<undefined>;
768
- redirect(ctx: LoginWfCtx): undefined;
1459
+ redirect(ctx: LoginWfCtx): undefined | Promise<undefined>;
769
1460
  /**
770
1461
  * Resolves the post-login redirect URL. Default reads
771
1462
  * `finalize.redirect`: `false` / `null` (the default) → no redirect, the
@@ -773,40 +1464,17 @@ declare class LoginWorkflow {
773
1464
  * `'home'` → `/`; `'referer'` → request `Referer` header (undefined when
774
1465
  * absent, falling back to the data response).
775
1466
  *
776
- * Consumers who want a computed redirect override this method.
1467
+ * Sync return type only the caller (`redirect` @Step's default body)
1468
+ * uses the URL inline; consumers needing async redirect resolution should
1469
+ * override the `redirect` @Step instead.
777
1470
  */
778
- protected resolveRedirect(_ctx: LoginWfCtx): string | undefined;
779
- private resolveClientIp;
1471
+ protected resolveRedirect(ctx: LoginWfCtx): string | undefined;
780
1472
  }
781
1473
  //#endregion
782
1474
  //#region src/workflows/recovery.workflow.options.d.ts
783
1475
  type RecoveryDeliveryMode = "magicLink" | "otp" | "choice";
784
1476
  type RecoveryOtpTransport = "sms" | "email";
785
1477
  interface RecoveryWorkflowOpts {
786
- delivery?: {
787
- mode?: RecoveryDeliveryMode;
788
- magicLinkTtlMs?: number;
789
- otp?: {
790
- transports?: RecoveryOtpTransport[];
791
- codeLength?: number;
792
- ttlMs?: number;
793
- resendCooldownMs?: number;
794
- };
795
- };
796
- preReset?: {
797
- requireKnownFactor?: boolean;
798
- };
799
- postReset?: {
800
- revokeAllSessions?: boolean;
801
- freshLoginRequired?: boolean;
802
- loginUrl?: string;
803
- };
804
- altActions?: {
805
- backToLogin?: boolean;
806
- };
807
- audit?: {
808
- enabled?: boolean;
809
- };
810
1478
  /**
811
1479
  * Replaceable form schemas. Each field defaults to the corresponding
812
1480
  * `.as` form shipped under `@aooth/auth-moost/atscript/models`.
@@ -821,34 +1489,10 @@ interface RecoveryWorkflowOpts {
821
1489
  }
822
1490
  /**
823
1491
  * Fully-resolved view used by the workflow at runtime — every nested group is
824
- * always populated by `mergeRecoveryOpts`, so schema conditions can read
825
- * `ctx.opts.<group>.<flag>` directly without optional chaining.
1492
+ * always populated by `mergeRecoveryOpts`, so step bodies can read
1493
+ * `this.opts.<group>.<flag>` directly without optional chaining.
826
1494
  */
827
1495
  interface ResolvedRecoveryWorkflowOpts {
828
- delivery: {
829
- mode: RecoveryDeliveryMode;
830
- magicLinkTtlMs: number;
831
- otp: {
832
- transports: RecoveryOtpTransport[];
833
- codeLength: number;
834
- ttlMs: number;
835
- resendCooldownMs: number;
836
- };
837
- };
838
- preReset: {
839
- requireKnownFactor: boolean;
840
- };
841
- postReset: {
842
- revokeAllSessions: boolean;
843
- freshLoginRequired: boolean;
844
- loginUrl: string;
845
- };
846
- altActions: {
847
- backToLogin: boolean;
848
- };
849
- audit: {
850
- enabled: boolean;
851
- };
852
1496
  forms: {
853
1497
  emailIdentifier: TAtscriptAnnotatedType;
854
1498
  pincode: TAtscriptAnnotatedType;
@@ -866,11 +1510,29 @@ declare function mergeRecoveryOpts(opts?: RecoveryWorkflowOpts): ResolvedRecover
866
1510
  //#endregion
867
1511
  //#region src/workflows/recovery.workflow.d.ts
868
1512
  interface RecoveryWfCtx {
869
- opts?: ResolvedRecoveryWorkflowOpts;
1513
+ delivery?: {
1514
+ mode: RecoveryDeliveryMode;
1515
+ otpTransports: RecoveryOtpTransport[];
1516
+ };
1517
+ preReset?: {
1518
+ requireKnownFactor: boolean;
1519
+ allowedFactors?: Array<"phone" | "totp">;
1520
+ };
1521
+ postReset?: {
1522
+ revokeAllSessions: boolean;
1523
+ freshLoginRequired: boolean;
1524
+ loginUrl: string;
1525
+ };
1526
+ altActions?: {
1527
+ backToLogin: boolean;
1528
+ };
1529
+ audit?: {
1530
+ enabled: boolean;
1531
+ };
870
1532
  email?: string;
871
1533
  username?: string;
872
1534
  selectedMode?: "magicLink" | "otp";
873
- /** Resolved delivery mode the workflow committed to (populated by `selectMode` or `init`). */
1535
+ /** Resolved delivery mode the workflow committed to (set by `prepare-delivery` for fixed modes, by `select-mode` for `'choice'`). */
874
1536
  resolvedMode?: "magicLink" | "otp";
875
1537
  otpTransport?: "sms" | "email";
876
1538
  otpCodeLength?: number;
@@ -878,19 +1540,69 @@ interface RecoveryWfCtx {
878
1540
  pinExpire?: number;
879
1541
  pinResendAllowedAt?: number;
880
1542
  pinVerified?: boolean;
1543
+ /** Mirror of `delivery.otpTransports.length`. Passed to `PincodeForm` so the `useDifferentTransport` action hides when only one transport is configured. */
1544
+ recoveryTransportCount?: number;
881
1545
  linkSent?: boolean;
882
1546
  factorVerified?: boolean;
1547
+ /**
1548
+ * Recovery factors the user is actually able to verify on this attempt —
1549
+ * intersection of `preReset.allowedFactors` (workflow whitelist) and
1550
+ * what the user has enrolled (e.g. phone only if a confirmed SMS method
1551
+ * exists). Populated by `verify-factor` before its form pauses and
1552
+ * consumed by `RecoveryFactorForm` via `@wf.context.pass` to render only
1553
+ * the available radio options.
1554
+ */
1555
+ availableRecoveryFactors?: Array<{
1556
+ key: string;
1557
+ label: string;
1558
+ }>;
883
1559
  passwordChanged?: boolean;
884
1560
  sessionsRevoked?: boolean;
885
1561
  tokensIssued?: boolean;
1562
+ /**
1563
+ * Subset of `pendingConsents[].id` the user ticked — set by
1564
+ * `processInlineConsent` after silent-dropping unknown ids.
1565
+ */
1566
+ acceptedConsentIds?: string[];
1567
+ /**
1568
+ * Wall-clock ms when `processInlineConsent` resolved the carrier-form
1569
+ * submission. Also the schema-gate for `persist-consents`.
1570
+ */
1571
+ consentsDecidedAt?: number;
1572
+ /** Set true by `persist-consents` after the batched `consentStore.save` call fires. */
1573
+ consentsPersisted?: boolean;
1574
+ /**
1575
+ * Descriptors for the customer-defined consents (terms, marketing,
1576
+ * jurisdiction, ...) the user still needs to accept. Populated once by
1577
+ * `prepare-consents` after username-bind; consumed by `WithInlineConsentForm`'s
1578
+ * dynamic `AsConsentArray` field on the carrier form (Phase 5).
1579
+ */
1580
+ pendingConsents?: ConsentDescriptor[];
886
1581
  /** Set by abort alt-actions (`backToLogin`). Gates all terminal steps. */
887
1582
  aborted?: boolean;
888
1583
  }
889
- declare class RecoveryWorkflow {
1584
+ /**
1585
+ * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1586
+ * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1587
+ * @Step methods populate — one entry per resolver. Library users typically
1588
+ * accept a payload of this shape on their `RecoveryWorkflow` subclass ctor /
1589
+ * test harness and have each `resolveXxx` return its matching key (falling
1590
+ * back to `super.resolveXxx(ctx)` for unset groups).
1591
+ */
1592
+ interface RecoveryPolicyOverrides {
1593
+ delivery?: NonNullable<RecoveryWfCtx["delivery"]>;
1594
+ preReset?: NonNullable<RecoveryWfCtx["preReset"]>;
1595
+ postReset?: NonNullable<RecoveryWfCtx["postReset"]>;
1596
+ altActions?: NonNullable<RecoveryWfCtx["altActions"]>;
1597
+ audit?: NonNullable<RecoveryWfCtx["audit"]>;
1598
+ }
1599
+ declare class RecoveryWorkflow extends AuthWorkflowBase {
890
1600
  protected readonly opts: ResolvedRecoveryWorkflowOpts;
891
1601
  protected readonly users: UserService;
892
1602
  protected readonly auth: AuthCredential;
893
- constructor(opts: RecoveryWorkflowOpts, users: UserService, auth: AuthCredential);
1603
+ protected readonly authOpts: AuthOpts;
1604
+ protected readonly consentStore: ConsentStore;
1605
+ constructor(opts: RecoveryWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
894
1606
  /**
895
1607
  * Dispatch an email or SMS event. Default throws — consumers MUST override
896
1608
  * if `delivery.mode` ever drives email/SMS (i.e. for any non-`magicLink`
@@ -904,22 +1616,76 @@ declare class RecoveryWorkflow {
904
1616
  * their audit sink.
905
1617
  */
906
1618
  protected audit(_event: AuditEvent): Promise<void>;
1619
+ /**
1620
+ * Resolve the delivery policy (mode + OTP transports). Override per-tenant
1621
+ * to drive magic-link vs OTP delivery preferences. Sync/async friendly.
1622
+ */
1623
+ protected resolveDelivery(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["delivery"]> | Promise<NonNullable<RecoveryWfCtx["delivery"]>>;
1624
+ /**
1625
+ * Resolve the pre-reset policy (requireKnownFactor + allowedFactors whitelist).
1626
+ * Override to enforce a factor check between the magic-link / OTP step and
1627
+ * the new-password form. `allowedFactors` omitted means both phone and TOTP
1628
+ * are eligible. Sync/async friendly.
1629
+ */
1630
+ protected resolvePreReset(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["preReset"]> | Promise<NonNullable<RecoveryWfCtx["preReset"]>>;
1631
+ /**
1632
+ * Resolve the post-reset policy (session revocation / fresh-login redirect /
1633
+ * loginUrl). Override per-tenant. `loginUrl` defaults to
1634
+ * `this.authOpts.loginUrl` (the cross-workflow shared login URL); customers
1635
+ * can still override per-tenant by overriding this resolver — the field
1636
+ * stays on the policy surface. Sync/async friendly.
1637
+ */
1638
+ protected resolvePostReset(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["postReset"]> | Promise<NonNullable<RecoveryWfCtx["postReset"]>>;
1639
+ /**
1640
+ * Resolve the alt-actions policy (whether `backToLogin` is offered on the
1641
+ * recovery forms). Override to hide the escape hatch per-tenant. Sync/async
1642
+ * friendly.
1643
+ */
1644
+ protected resolveAltActions(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["altActions"]> | Promise<NonNullable<RecoveryWfCtx["altActions"]>>;
1645
+ /**
1646
+ * Resolve the audit policy (whether recovery.* audit events fire). Override
1647
+ * to route audit-log emission per-tenant. Sync/async friendly.
1648
+ */
1649
+ protected resolveAudit(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["audit"]> | Promise<NonNullable<RecoveryWfCtx["audit"]>>;
1650
+ prepareDelivery(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1651
+ /**
1652
+ * Apply resolved delivery to ctx — also auto-resolves derived ctx fields:
1653
+ * - `resolvedMode` (when mode !== 'choice' — `'choice'` defers to `select-mode`)
1654
+ * - `recoveryTransportCount` (mirrored to ctx for the `useDifferentTransport` form gate)
1655
+ *
1656
+ * Validates the otpTransports-not-empty invariant at step time (replacing
1657
+ * the old construction-time `validateOpts` check; the value is now
1658
+ * ctx-driven so the check has to fire at step time).
1659
+ */
1660
+ private applyResolvedDelivery;
1661
+ preparePreReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1662
+ preparePostReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1663
+ prepareAltActions(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1664
+ prepareAudit(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1665
+ /**
1666
+ * Populate `ctx.pendingConsents` with the customer-defined general-consent
1667
+ * descriptors (terms, marketing, jurisdiction, ...) the user still needs to
1668
+ * accept. Phase 4 transport only — nothing reads `ctx.pendingConsents` yet;
1669
+ * Phase 5 will migrate the carrier `SetPasswordForm` from the
1670
+ * `WithInlineConsentForm` static-checkbox mixin onto this dynamic array.
1671
+ *
1672
+ * Username MUST be bound before we fetch consents — the schema places this
1673
+ * step AFTER the `!ctx.username` break gate, so the `if (!ctx.username)`
1674
+ * guard is belt-and-brace for future refactors that might re-order the
1675
+ * schema.
1676
+ */
1677
+ prepareConsents(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
907
1678
  flow(): void;
908
- init(ctx: RecoveryWfCtx): undefined;
909
1679
  /**
910
- * Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
911
- * conditions to read. Default: drop the `forms` group (atscript form classes
912
- * are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
913
- * Step bodies still consult the form classes via `this.opts.forms.*`.
1680
+ * First step of the workflow; remains as a no-op override hook for
1681
+ * consumers. Policy populated by the dedicated `prepare-<group>` steps.
914
1682
  *
915
- * Consumers who extend the opts type with non-JSON values can override this
916
- * to strip them so `AsWfStore`'s plain-JSON persistence doesn't choke.
1683
+ * Return type is `undefined | Promise<undefined>` so consumers can override
1684
+ * with `async init(...)` without the default fast-path paying a Promise
1685
+ * allocation (the wf engine awaits only when the return value is a Promise).
917
1686
  */
918
- protected snapshotOpts(opts: ResolvedRecoveryWorkflowOpts): ResolvedRecoveryWorkflowOpts;
919
- request(input: {
920
- email?: string;
921
- action?: string;
922
- } | undefined, ctx: RecoveryWfCtx): Promise<unknown>;
1687
+ init(_ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1688
+ request(ctx: RecoveryWfCtx): Promise<unknown>;
923
1689
  /**
924
1690
  * Resolves the recovery-step `email` input to the `username` (user-id) that
925
1691
  * `UserService.getUser` expects. Default: returns the email unchanged (treats
@@ -927,21 +1693,24 @@ declare class RecoveryWorkflow {
927
1693
  * `email` MUST override this; return `null` when no user matches.
928
1694
  */
929
1695
  protected emailToUserId(email: string): Promise<string | null>;
930
- selectMode(input: {
931
- mode?: string;
932
- action?: string;
933
- } | undefined, ctx: RecoveryWfCtx): unknown;
1696
+ selectMode(ctx: RecoveryWfCtx): unknown;
934
1697
  sendMagicLink(ctx: RecoveryWfCtx): unknown;
935
1698
  sendOtp(ctx: RecoveryWfCtx): Promise<undefined>;
936
- checkOtp(input: {
937
- code?: string;
938
- action?: string;
939
- } | undefined, ctx: RecoveryWfCtx): Promise<unknown>;
940
- verifyFactor(input: {
941
- factor?: string;
942
- value?: string;
943
- action?: string;
944
- } | undefined, ctx: RecoveryWfCtx): Promise<unknown>;
1699
+ checkOtp(ctx: RecoveryWfCtx): Promise<unknown>;
1700
+ verifyFactor(ctx: RecoveryWfCtx): Promise<unknown>;
1701
+ /**
1702
+ * Returns the factor options to show on `RecoveryFactorForm`. Default:
1703
+ * intersection of `preReset.allowedFactors` (workflow whitelist —
1704
+ * `undefined` means both `phone` and `totp` are eligible) and the kinds
1705
+ * the user has actually enrolled (`phone` if a confirmed SMS method
1706
+ * exists, `totp` if a confirmed TOTP method exists). Override to add
1707
+ * custom factors (e.g. security questions) — call `super` to keep the
1708
+ * built-in pair.
1709
+ */
1710
+ protected loadAvailableRecoveryFactors(ctx: RecoveryWfCtx): Promise<Array<{
1711
+ key: string;
1712
+ label: string;
1713
+ }>>;
945
1714
  /**
946
1715
  * Verifies a recovery factor against the user's enrolled MFA methods.
947
1716
  * Default: supports `'phone'` (phone last-4 match) and `'totp'` (current
@@ -956,14 +1725,16 @@ declare class RecoveryWorkflow {
956
1725
  value: string;
957
1726
  ctx: RecoveryWfCtx;
958
1727
  }): Promise<boolean>;
959
- setPassword(input: {
960
- newPassword?: string;
961
- confirmPassword?: string;
962
- action?: string;
963
- } | undefined, ctx: RecoveryWfCtx): Promise<unknown>;
1728
+ setPassword(ctx: RecoveryWfCtx): Promise<unknown>;
964
1729
  revokeSessions(ctx: RecoveryWfCtx): Promise<undefined>;
965
1730
  auditStep(ctx: RecoveryWfCtx): Promise<undefined>;
966
- freshLoginFinish(_ctx: RecoveryWfCtx): undefined;
1731
+ /**
1732
+ * Batched consent persistence — delegates to
1733
+ * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
1734
+ * audit-friendly-default / idempotency / silent-drop contract.
1735
+ */
1736
+ persistConsentsStep(ctx: RecoveryWfCtx): Promise<undefined>;
1737
+ freshLoginFinish(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
967
1738
  autoLoginFinish(ctx: RecoveryWfCtx): Promise<undefined>;
968
1739
  /**
969
1740
  * Send the generic "if an account exists, you'll receive instructions"
@@ -994,31 +1765,14 @@ interface PreparedUserInput {
994
1765
  type DuplicateAction = "allow" | "reject" | "reuseAsReInvite";
995
1766
  type InviteSendMode = "email" | "shareableLink" | "choice";
996
1767
  interface InviteWorkflowOpts {
997
- adminForm?: {
998
- collectRoles?: boolean;
999
- };
1000
- send?: {
1001
- mode?: InviteSendMode;
1002
- tokenTtlMs?: number;
1003
- };
1004
- accept?: {
1005
- alreadyAcceptedRedirectUrl?: string;
1006
- freshLoginRequired?: boolean;
1007
- loginUrl?: string;
1008
- showConfirmation?: boolean;
1009
- confirmationMessage?: string;
1010
- };
1011
- cancellation?: {
1012
- allowed?: boolean;
1013
- };
1014
- audit?: {
1015
- enabled?: boolean;
1016
- };
1017
1768
  /**
1018
1769
  * Replaceable form schemas. Each field defaults to the corresponding
1019
1770
  * `.as` form shipped under `@aooth/auth-moost/atscript/models`.
1020
1771
  */
1021
1772
  forms?: {
1773
+ enrollAddress?: TAtscriptAnnotatedType;
1774
+ enrollConfirm?: TAtscriptAnnotatedType;
1775
+ enrollPickMethod?: TAtscriptAnnotatedType;
1022
1776
  invite?: TAtscriptAnnotatedType;
1023
1777
  inviteEmail?: TAtscriptAnnotatedType;
1024
1778
  inviteSendMode?: TAtscriptAnnotatedType;
@@ -1027,31 +1781,14 @@ interface InviteWorkflowOpts {
1027
1781
  }
1028
1782
  /**
1029
1783
  * Fully-resolved view used by the workflow at runtime — every nested group is
1030
- * always populated by `mergeInviteOpts`, so schema conditions can read
1031
- * `ctx.opts.<group>.<flag>` directly without optional chaining.
1784
+ * always populated by `mergeInviteOpts`, so step bodies can read
1785
+ * `this.opts.<group>.<flag>` directly without optional chaining.
1032
1786
  */
1033
1787
  interface ResolvedInviteWorkflowOpts {
1034
- adminForm: {
1035
- collectRoles: boolean;
1036
- };
1037
- send: {
1038
- mode: InviteSendMode;
1039
- tokenTtlMs: number;
1040
- };
1041
- accept: {
1042
- alreadyAcceptedRedirectUrl: string;
1043
- freshLoginRequired: boolean;
1044
- loginUrl: string;
1045
- showConfirmation: boolean;
1046
- confirmationMessage: string;
1047
- };
1048
- cancellation: {
1049
- allowed: boolean;
1050
- };
1051
- audit: {
1052
- enabled: boolean;
1053
- };
1054
1788
  forms: {
1789
+ enrollAddress: TAtscriptAnnotatedType;
1790
+ enrollConfirm: TAtscriptAnnotatedType;
1791
+ enrollPickMethod: TAtscriptAnnotatedType;
1055
1792
  invite: TAtscriptAnnotatedType;
1056
1793
  inviteEmail: TAtscriptAnnotatedType;
1057
1794
  inviteSendMode: TAtscriptAnnotatedType;
@@ -1072,14 +1809,35 @@ type InvitePrepareUserInput = PreparedUserInput;
1072
1809
  //#endregion
1073
1810
  //#region src/workflows/invite.workflow.d.ts
1074
1811
  interface InviteWfCtx {
1075
- opts?: ResolvedInviteWorkflowOpts;
1812
+ adminForm?: {
1813
+ collectRoles: boolean;
1814
+ };
1815
+ send?: {
1816
+ mode: InviteSendMode;
1817
+ };
1818
+ accept?: {
1819
+ alreadyAcceptedRedirectUrl: string;
1820
+ freshLoginRequired: boolean;
1821
+ loginUrl: string;
1822
+ showConfirmation: boolean;
1823
+ confirmationMessage: string;
1824
+ };
1825
+ cancellation?: {
1826
+ allowed: boolean;
1827
+ };
1828
+ audit?: {
1829
+ enabled: boolean;
1830
+ };
1831
+ mfa?: {
1832
+ issuer: string;
1833
+ };
1076
1834
  /** Boolean projection of `this.getProfileForm() !== undefined` — schema gates on it. */
1077
1835
  acceptProfileFormPresent?: boolean;
1078
1836
  /**
1079
- * Populated by `invitePrepareAvailableRoles` when the override returns a list.
1837
+ * Populated by `prepare-available-roles` when the override returns a list.
1080
1838
  * Surfaced into the `InviteForm` via `@wf.context.pass 'availableRoles'` so
1081
1839
  * the role multi-select renders the whitelisted choices; also used by
1082
- * `inviteAdminInviteForm` to reject admin-submitted roles outside the list.
1840
+ * `admin-form` to reject admin-submitted roles outside the list.
1083
1841
  */
1084
1842
  availableRoles?: string[];
1085
1843
  email?: string;
@@ -1088,27 +1846,97 @@ interface InviteWfCtx {
1088
1846
  firstName?: string;
1089
1847
  lastName?: string;
1090
1848
  roles?: string[];
1091
- /** Populated by `inviteSelectSendMode` (when `send.mode === 'choice'`). */
1849
+ /**
1850
+ * Extras dict prepared by `build-user-extras` (calls `prepareUser`) and
1851
+ * consumed by `create-user` to populate the user-row fields beyond the
1852
+ * base credential shape. Split apart so consumers can inject e.g. a
1853
+ * tenant-validation step between extras-build and create-user without
1854
+ * copying either body.
1855
+ */
1856
+ userExtras?: Record<string, unknown>;
1857
+ /** Populated by `select-send-mode` (when `send.mode === 'choice'`). */
1092
1858
  selectedSendMode?: "email" | "shareableLink";
1093
- /** Resolved send mode the workflow committed to (set in `inviteInit` or `inviteSelectSendMode`). */
1859
+ /** Resolved send mode the workflow committed to (set in `prepare-send` or `select-send-mode`). */
1094
1860
  resolvedSendMode?: "email" | "shareableLink";
1095
- /** Populated by `inviteReturnShareableLink` so the admin's UI can display it. */
1861
+ /** Populated by `return-shareable-link` so the admin's UI can display it. */
1096
1862
  shareableLinkUrl?: string;
1097
- /** Marks that `inviteSendInviteEmail` already emitted the outlet — resume → advance. */
1863
+ /** Marks that `send-email` already emitted the outlet — resume → advance. */
1098
1864
  linkSent?: boolean;
1099
- /** Detected at `inviteCheckPendingInvitation`; triggers `inviteIdempotentRedirect`. */
1865
+ /** Detected at `check-pending-invitation`; triggers `idempotent-redirect`. */
1100
1866
  alreadyAccepted?: boolean;
1101
1867
  passwordSet?: boolean;
1102
- /** Raw input from `inviteCollectProfile`. */
1868
+ enrollMethod?: "sms" | "email" | "totp";
1869
+ enrollAddress?: string;
1870
+ enrollSecret?: string;
1871
+ enrollUri?: string;
1872
+ enrollAvailableTransports?: Array<"sms" | "email" | "totp">;
1873
+ /**
1874
+ * MFA policy (set by `inviteSetupMfa` setter — overridable per consumer).
1875
+ * - `'required'` — invitee MUST enroll a second factor BEFORE activation.
1876
+ * - `'optional'` — invitee is prompted but may `skip` the enrollment form.
1877
+ * - `'disabled'` — enrollment loop is skipped entirely.
1878
+ */
1879
+ mfaMode?: "required" | "optional" | "disabled";
1880
+ /** Available MFA transports (set by `inviteSetupMfa` setter — overridable per consumer). */
1881
+ availableMfaTransports?: Array<"sms" | "email" | "totp">;
1882
+ /**
1883
+ * Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
1884
+ * `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
1885
+ * hide unless mode is `'optional'`.
1886
+ */
1887
+ enrollMode?: "required" | "optional";
1888
+ enrollDone?: boolean;
1889
+ /** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
1890
+ enrollPincodeCooldown?: number;
1891
+ /** Pincode scratch shared with the enrollment helper. */
1892
+ pin?: string;
1893
+ pinExpire?: number;
1894
+ pinSentTo?: string;
1895
+ /** Raw input from `collect-profile`. */
1103
1896
  profile?: Record<string, unknown>;
1104
1897
  profileApplied?: boolean;
1105
1898
  pendingInvitationCleared?: boolean;
1106
1899
  activated?: boolean;
1107
1900
  confirmationShown?: boolean;
1108
1901
  tokensIssued?: boolean;
1902
+ /**
1903
+ * Subset of `pendingConsents[].id` the user ticked — set by
1904
+ * `processInlineConsent` after silent-dropping unknown ids.
1905
+ */
1906
+ acceptedConsentIds?: string[];
1907
+ /**
1908
+ * Wall-clock ms when `processInlineConsent` resolved the carrier-form
1909
+ * submission. Also the schema-gate for `persist-consents`.
1910
+ */
1911
+ consentsDecidedAt?: number;
1912
+ /** Set true by `persist-consents` after the batched `consentStore.save` call fires. */
1913
+ consentsPersisted?: boolean;
1914
+ /**
1915
+ * Descriptors for the customer-defined consents (terms, marketing,
1916
+ * jurisdiction, ...) the user still needs to accept. Populated once by
1917
+ * `prepare-consents` after username-bind; consumed by `WithInlineConsentForm`'s
1918
+ * dynamic `AsConsentArray` field on the carrier form (Phase 5).
1919
+ */
1920
+ pendingConsents?: ConsentDescriptor[];
1109
1921
  /** Set true by abort alt-actions (`cancel`). Gates all terminal steps. */
1110
1922
  aborted?: boolean;
1111
1923
  }
1924
+ /**
1925
+ * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1926
+ * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1927
+ * @Step methods populate — one entry per resolver. Library users typically
1928
+ * accept a payload of this shape on their `InviteWorkflow` subclass ctor /
1929
+ * test harness and have each `resolveXxx` return its matching key (falling
1930
+ * back to `super.resolveXxx(ctx)` for unset groups).
1931
+ */
1932
+ interface InvitePolicyOverrides {
1933
+ adminForm?: NonNullable<InviteWfCtx["adminForm"]>;
1934
+ send?: NonNullable<InviteWfCtx["send"]>;
1935
+ accept?: NonNullable<InviteWfCtx["accept"]>;
1936
+ cancellation?: NonNullable<InviteWfCtx["cancellation"]>;
1937
+ audit?: NonNullable<InviteWfCtx["audit"]>;
1938
+ mfa?: NonNullable<InviteWfCtx["mfa"]>;
1939
+ }
1112
1940
  /** Trim + de-duplicate role identifiers submitted via the admin invite form. */
1113
1941
  declare function parseInviteRoles(input?: string[]): string[];
1114
1942
  /**
@@ -1116,7 +1944,9 @@ declare function parseInviteRoles(input?: string[]): string[];
1116
1944
  * inherit the class-level `@ArbacResource('auth.invite') @ArbacAction('start')`
1117
1945
  * so every admin-side step event is gated. Apps that wire
1118
1946
  * `arbacAuthorizeInterceptor` globally grant admin a single rule:
1119
- * `allow('auth.invite', 'start')`.
1947
+ * `allow('auth.invite', 'start')`. The ARBAC resource name is intentionally
1948
+ * distinct from the wfid path (`auth/invite/start`) — RBAC policy ids and
1949
+ * wfid namespacing are separate naming schemes.
1120
1950
  *
1121
1951
  * The three `@Workflow` body methods (`inviteFlow` / `reInviteFlow` /
1122
1952
  * `cancelInviteFlow`) are `@Public()` because the wf adapter dispatches the
@@ -1127,20 +1957,22 @@ declare function parseInviteRoles(input?: string[]): string[];
1127
1957
  *
1128
1958
  * Phase-B steps (post `ctx.linkSent`, accept tail) are method-level
1129
1959
  * `@Public()` because they fire on the anonymous magic-link resume.
1130
- * `inviteSendInviteEmail` / `inviteReturnShareableLink` are the boundary:
1131
- * also `@Public()` because the @prostojs/wf runtime re-enters the saved step
1132
- * on resume (the loop restarts at `indexes[level]`, not after it). Their
1133
- * bodies are idempotent via `if (ctx.linkSent) return`.
1960
+ * `send-email` / `return-shareable-link` are the boundary: also `@Public()`
1961
+ * because the @prostojs/wf runtime re-enters the saved step on resume (the
1962
+ * loop restarts at `indexes[level]`, not after it). Their bodies are
1963
+ * idempotent via `if (ctx.linkSent) return`.
1134
1964
  *
1135
- * `auth.reInvite` / `auth.cancelInvite` are admin-only end-to-end (admin
1136
- * confirms in their own UI; no anonymous boundary), so their phase-A steps
1137
- * stay class-gated under the same `auth.invite` / `start` grant.
1965
+ * `auth/invite/resend` / `auth/invite/cancel` are admin-only end-to-end
1966
+ * (admin confirms in their own UI; no anonymous boundary), so their phase-A
1967
+ * steps stay class-gated under the same `auth.invite` / `start` grant.
1138
1968
  */
1139
- declare class InviteWorkflow {
1969
+ declare class InviteWorkflow extends AuthWorkflowBase {
1140
1970
  protected readonly opts: ResolvedInviteWorkflowOpts;
1141
1971
  protected readonly users: UserService;
1142
1972
  protected readonly auth: AuthCredential;
1143
- constructor(opts: InviteWorkflowOpts, users: UserService, auth: AuthCredential);
1973
+ protected readonly authOpts: AuthOpts;
1974
+ protected readonly consentStore: ConsentStore;
1975
+ constructor(opts: InviteWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
1144
1976
  /**
1145
1977
  * Dispatch an email or SMS event. Default throws — the default invite send
1146
1978
  * uses `outletEmail` (handled by `createAuthEmailOutlet`) so this method is
@@ -1165,7 +1997,7 @@ declare class InviteWorkflow {
1165
1997
  * Return the list of selectable role identifiers for the admin invite form.
1166
1998
  * When defined AND `adminForm.collectRoles` is true → form ships
1167
1999
  * `ctx.availableRoles` so the UI renders a multi-select AND the
1168
- * `inviteAdminInviteForm` step rejects admin-submitted roles outside the
2000
+ * `admin-form` step rejects admin-submitted roles outside the
1169
2001
  * list. When `undefined` (default) → no whitelist is enforced and any role
1170
2002
  * value the admin form supplies is accepted.
1171
2003
  */
@@ -1190,7 +2022,7 @@ declare class InviteWorkflow {
1190
2022
  profile: Record<string, unknown>;
1191
2023
  }): Promise<void>;
1192
2024
  /**
1193
- * Override the structural duplicate rule for `inviteAdminInviteForm`.
2025
+ * Override the structural duplicate rule for `admin-form`.
1194
2026
  * Default: any existing row → `'reject'`; nothing → `'allow'`. Multi-tenant
1195
2027
  * apps that allow re-inviting the same email into a different tenant
1196
2028
  * override to return `'allow'` for those cases.
@@ -1201,67 +2033,173 @@ declare class InviteWorkflow {
1201
2033
  }): Promise<DuplicateAction>;
1202
2034
  /**
1203
2035
  * Return the consumer-supplied `.as` form schema rendered in the
1204
- * `inviteCollectProfile` step. `undefined` (default) skips the step
2036
+ * `collect-profile` step. `undefined` (default) skips the step
1205
2037
  * entirely (just password collection).
1206
2038
  */
1207
2039
  protected getProfileForm(): TAtscriptAnnotatedType | undefined;
1208
2040
  /**
1209
- * Returns the JSON-safe projection of `opts` stashed onto `ctx` for schema
1210
- * conditions to read. Default: drop the `forms` group (atscript form classes
1211
- * are not plain JSON) so `AsWfStore`'s plain-JSON persistence doesn't choke.
1212
- * Step bodies still consult the form classes via `this.opts.forms.*`.
2041
+ * Resolve the admin-form policy (whether to collect roles on the admin
2042
+ * invite form). Override per-tenant. Sync/async friendly.
2043
+ */
2044
+ protected resolveAdminForm(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["adminForm"]> | Promise<NonNullable<InviteWfCtx["adminForm"]>>;
2045
+ /**
2046
+ * Resolve the send-mode policy (`'email'` / `'shareableLink'` / `'choice'`).
2047
+ * Override to drive per-tenant magic-link delivery preferences. Sync/async
2048
+ * friendly.
2049
+ */
2050
+ protected resolveSend(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["send"]> | Promise<NonNullable<InviteWfCtx["send"]>>;
2051
+ /**
2052
+ * Resolve the accept-tail policy (idempotent-redirect URL, fresh-login gate,
2053
+ * loginUrl, confirmation message). Override per-tenant. Sync/async friendly.
2054
+ * `loginUrl` defaults to `this.authOpts.loginUrl` (the cross-workflow shared
2055
+ * login URL); customers can still override per-tenant by overriding this
2056
+ * resolver — the field stays on the policy surface.
2057
+ */
2058
+ protected resolveAccept(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["accept"]> | Promise<NonNullable<InviteWfCtx["accept"]>>;
2059
+ /**
2060
+ * Resolve the cancellation policy (whether `auth.cancelInvite` is allowed).
2061
+ * Override to disable hard-delete per-tenant. Sync/async friendly.
2062
+ */
2063
+ protected resolveCancellation(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["cancellation"]> | Promise<NonNullable<InviteWfCtx["cancellation"]>>;
2064
+ /**
2065
+ * Resolve the audit policy (whether invite.* audit events fire). Override
2066
+ * to route audit-log emission per-tenant. Sync/async friendly.
2067
+ */
2068
+ protected resolveAudit(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["audit"]> | Promise<NonNullable<InviteWfCtx["audit"]>>;
2069
+ /**
2070
+ * Resolve the MFA-issuer policy (TOTP provisioning issuer string rendered
2071
+ * in the authenticator app). Default tracks `this.authOpts.totpIssuer` —
2072
+ * customers override the resolver for per-tenant issuers. Pincode timers/
2073
+ * length live on `AuthOpts.mfa`. Sync/async friendly.
2074
+ */
2075
+ protected resolveMfa(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["mfa"]> | Promise<NonNullable<InviteWfCtx["mfa"]>>;
2076
+ prepareAdminForm(ctx: InviteWfCtx): undefined | Promise<undefined>;
2077
+ prepareSend(ctx: InviteWfCtx): undefined | Promise<undefined>;
2078
+ prepareAccept(ctx: InviteWfCtx): undefined | Promise<undefined>;
2079
+ prepareCancellation(ctx: InviteWfCtx): undefined | Promise<undefined>;
2080
+ prepareAudit(ctx: InviteWfCtx): undefined | Promise<undefined>;
2081
+ prepareMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
2082
+ /**
2083
+ * Populate `ctx.pendingConsents` with the customer-defined general-consent
2084
+ * descriptors (terms, marketing, jurisdiction, ...) the invitee still needs
2085
+ * to accept. Phase 4 transport only — nothing reads `ctx.pendingConsents`
2086
+ * yet; Phase 5 will migrate the carrier `SetPasswordForm` from the
2087
+ * `WithInlineConsentForm` static-checkbox mixin onto this dynamic array.
1213
2088
  *
1214
- * Consumers who extend the opts type with non-JSON values can override this
1215
- * to strip them so `AsWfStore`'s plain-JSON persistence doesn't choke.
2089
+ * Username MUST be bound before we fetch consents schema places this step
2090
+ * AFTER `check-pending-invitation` (which sets `ctx.username` from the
2091
+ * pending-invite row) inside the `linkSent` accept-tail subflow, so the
2092
+ * `if (!ctx.username)` guard is belt-and-brace. `@Public()` is required
2093
+ * because this step fires on the anonymous magic-link resume side of the
2094
+ * workflow.
1216
2095
  */
1217
- protected snapshotOpts(opts: ResolvedInviteWorkflowOpts): ResolvedInviteWorkflowOpts;
2096
+ prepareConsents(ctx: InviteWfCtx): undefined | Promise<undefined>;
1218
2097
  inviteFlow(): void;
1219
2098
  reInviteFlow(): void;
1220
2099
  cancelInviteFlow(): void;
1221
- init(ctx: InviteWfCtx): undefined;
2100
+ init(ctx: InviteWfCtx): undefined | Promise<undefined>;
1222
2101
  prepareAvailableRoles(ctx: InviteWfCtx): Promise<undefined>;
1223
- selectSendMode(input: {
1224
- mode?: string;
1225
- action?: string;
1226
- } | undefined, ctx: InviteWfCtx): unknown;
1227
- adminInviteForm(input: {
1228
- email?: string;
1229
- firstName?: string;
1230
- lastName?: string;
1231
- roles?: string[];
1232
- action?: string;
1233
- } | undefined, ctx: InviteWfCtx): Promise<unknown>;
2102
+ selectSendMode(ctx: InviteWfCtx): unknown;
2103
+ adminInviteForm(ctx: InviteWfCtx): Promise<unknown>;
1234
2104
  inferRolesStep(ctx: InviteWfCtx): Promise<undefined>;
1235
- preCreateUser(ctx: InviteWfCtx): Promise<undefined>;
2105
+ /**
2106
+ * Build the extras dict that `create-user` merges into the new user
2107
+ * row. Calls `prepareUser({email, firstName, lastName, roles, invitedBy})`
2108
+ * and writes the result onto `ctx.userExtras`. Split out of the old
2109
+ * `invitePreCreateUser` step so consumers can inject e.g. a
2110
+ * tenant-validation step between extras-build and create-user without
2111
+ * copying the createUser body.
2112
+ */
2113
+ buildUserExtras(ctx: InviteWfCtx): Promise<undefined>;
2114
+ /**
2115
+ * Create the user row from `ctx.userExtras` (plus the admin-supplied
2116
+ * `ctx.roles`), translate store-level CONFLICT into HTTP 409, then stamp
2117
+ * `pendingInvitation = true` via a deep-merge update so the
2118
+ * `createUser`-applied account defaults (`active: false`, `locked: false`)
2119
+ * survive. Split out of the old `invitePreCreateUser` step so consumers can
2120
+ * override extras-build (`build-user-extras`) without touching the
2121
+ * store-write transaction.
2122
+ */
2123
+ createUserStep(ctx: InviteWfCtx): Promise<undefined>;
1236
2124
  sendInviteEmail(ctx: InviteWfCtx): unknown;
1237
2125
  returnShareableLink(ctx: InviteWfCtx): unknown;
1238
- loadPendingUser(input: {
1239
- email?: string;
1240
- action?: string;
1241
- } | undefined, ctx: InviteWfCtx): Promise<unknown>;
2126
+ loadPendingUser(ctx: InviteWfCtx): Promise<unknown>;
1242
2127
  checkPendingInvitation(ctx: InviteWfCtx): Promise<undefined>;
1243
- idempotentRedirect(ctx: InviteWfCtx): undefined;
1244
- preparePasswordRules(ctx: InviteWfCtx): undefined;
1245
- createPasswordForm(input: {
1246
- newPassword?: string;
1247
- confirmPassword?: string;
1248
- action?: string;
1249
- } | undefined, ctx: InviteWfCtx): Promise<unknown>;
1250
- collectProfile(input: Record<string, unknown> | undefined, ctx: InviteWfCtx): Promise<unknown>;
2128
+ idempotentRedirect(ctx: InviteWfCtx): undefined | Promise<undefined>;
2129
+ preparePasswordRules(ctx: InviteWfCtx): undefined | Promise<undefined>;
2130
+ createPasswordForm(ctx: InviteWfCtx): Promise<unknown>;
2131
+ /**
2132
+ * Build the `MfaEnrollDeps` payload shared by all three invite enrollment
2133
+ * step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
2134
+ * `EnrollPickMethodForm` can hide the `skip` action unless mode is
2135
+ * `'optional'`). Omits `onComplete` because invite's enrollment while-loop
2136
+ * is gated on `!enrollDone` directly — no mirror needed.
2137
+ */
2138
+ private buildInviteEnrollDeps;
2139
+ /**
2140
+ * Prepare MFA enrolment setup: writes `ctx.mfaMode`,
2141
+ * `ctx.availableMfaTransports`, and pre-picks `ctx.enrollMethod` when only
2142
+ * one transport is available. Override to compute any of the three from
2143
+ * tenant policy / invitee role / request context in a single hook. Return
2144
+ * type allows a sync override (skip the promise round-trip) when no async
2145
+ * work is needed.
2146
+ */
2147
+ inviteSetupMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
2148
+ /**
2149
+ * Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
2150
+ * transport, otherwise pauses for the picker form. When TOTP is picked, the
2151
+ * secret is provisioned in the same step body (see `enrollPickPhase`).
2152
+ */
2153
+ inviteEnrollPickMethod(ctx: InviteWfCtx): undefined | Promise<undefined>;
2154
+ /**
2155
+ * Forced MFA enrollment — Phase 2 (collect sms/email address + send
2156
+ * pincode). Gated out for totp by the schema condition.
2157
+ */
2158
+ inviteEnrollAddress(ctx: InviteWfCtx): undefined | Promise<undefined>;
2159
+ /**
2160
+ * Forced MFA enrollment — Phase 3 (verify code + activate method). Sets
2161
+ * `enrollDone` on success, which the schema's enrollment while-loop reads
2162
+ * as the exit signal directly.
2163
+ */
2164
+ inviteEnrollConfirm(ctx: InviteWfCtx): undefined | Promise<undefined>;
2165
+ collectProfile(ctx: InviteWfCtx): Promise<unknown>;
1251
2166
  applyProfileStep(ctx: InviteWfCtx): Promise<undefined>;
2167
+ /**
2168
+ * Consumer extension point — override in your subclass to inject extra
2169
+ * accept-tail logic (input pauses, alt actions, persistence). Default:
2170
+ * no-op. Runs AFTER profile collection, BEFORE activation. Signature is
2171
+ * intentionally arg-less; read ctx + form input via composables
2172
+ * (`useWfState`, `useAtscriptWf`) in the override body.
2173
+ */
2174
+ inviteExtraStep(): unknown;
2175
+ /**
2176
+ * Batched consent persistence — delegates to
2177
+ * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
2178
+ * audit-friendly-default / idempotency / silent-drop contract.
2179
+ */
2180
+ persistConsentsStep(ctx: InviteWfCtx): Promise<undefined>;
1252
2181
  unsetPendingInvitation(ctx: InviteWfCtx): Promise<undefined>;
1253
2182
  activateUser(ctx: InviteWfCtx): Promise<undefined>;
1254
- confirmation(ctx: InviteWfCtx): undefined;
1255
- freshLoginFinish(_ctx: InviteWfCtx): undefined;
2183
+ confirmation(ctx: InviteWfCtx): undefined | Promise<undefined>;
2184
+ freshLoginFinish(ctx: InviteWfCtx): undefined | Promise<undefined>;
1256
2185
  autoLoginFinish(ctx: InviteWfCtx): Promise<undefined>;
1257
- cancelInvite(input: {
1258
- email?: string;
1259
- } | undefined, ctx: InviteWfCtx): Promise<unknown>;
2186
+ cancelInvite(ctx: InviteWfCtx): Promise<unknown>;
1260
2187
  private abort;
1261
2188
  private loadUserOrNull;
1262
2189
  private emitAudit;
1263
2190
  }
1264
2191
  //#endregion
2192
+ //#region src/workflows/default-workflows.d.ts
2193
+ declare class DefaultLoginWorkflow extends LoginWorkflow {
2194
+ constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2195
+ }
2196
+ declare class DefaultInviteWorkflow extends InviteWorkflow {
2197
+ constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2198
+ }
2199
+ declare class DefaultRecoveryWorkflow extends RecoveryWorkflow {
2200
+ constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2201
+ }
2202
+ //#endregion
1265
2203
  //#region src/workflows/auth-email-outlet.d.ts
1266
2204
  interface AuthEmailOutletDeps {
1267
2205
  emailSender: EmailSender$1;
@@ -1276,4 +2214,12 @@ interface AuthEmailOutletDeps {
1276
2214
  */
1277
2215
  declare function createAuthEmailOutlet(deps: AuthEmailOutletDeps): WfOutlet;
1278
2216
  //#endregion
1279
- export { type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, type AuthRefreshBody, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type ConcurrencyLimitOptions, DEFAULT_AUTH_WORKFLOWS, type DeliverEmail, type DeliverPayload, type DeliverSms, type DuplicateAction, type EmailSender, type InvitePrepareUserInput, type InviteSendMode, type InviteWfCtx, InviteWorkflow, type InviteWorkflowOpts, type IssueResult, type LoginRedirect, type LoginWfCtx, LoginWorkflow, type LoginWorkflowOpts, type MfaSummary, type MfaTransport, type PreparedUserInput, Public, type RecoveryDeliveryMode, type RecoveryOtpTransport, type RecoveryWfCtx, RecoveryWorkflow, type RecoveryWorkflowOpts, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedInviteWorkflowOpts, type ResolvedLoginWorkflowOpts, type ResolvedRecoveryWorkflowOpts, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, authGuardInterceptor, createAuthEmailOutlet, generateMagicLinkToken, getAuthMate, mergeInviteOpts, mergeLoginOpts, mergeRecoveryOpts, parseInviteRoles, useAuth };
2217
+ //#region src/workflows/auth-shareable-link-outlet.d.ts
2218
+ interface AuthShareableLinkOutletDeps {
2219
+ buildMagicLinkUrl: BuildMagicLinkUrl$1;
2220
+ /** Fallback TTL when the workflow context omits `expiresAtMs`. */
2221
+ magicLinkTtlMs: (kind: AuthEmailKind$1) => number;
2222
+ }
2223
+ declare function createAuthShareableLinkOutlet(deps: AuthShareableLinkOutletDeps): WfOutlet;
2224
+ //#endregion
2225
+ export { type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, AuthOpts, type AuthRefreshBody, type AuthShareableLinkOutletDeps, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type ConcurrencyLimitOptions, type ConsentDescriptor, type ConsentEvent, ConsentStore, DEFAULT_AUTH_WORKFLOWS, DefaultInviteWorkflow, DefaultLoginWorkflow, DefaultRecoveryWorkflow, type DeliverEmail, type DeliverPayload, type DeliverSms, type DuplicateAction, type EmailSender, type InvitePolicyOverrides, type InvitePrepareUserInput, type InviteSendMode, type InviteWfCtx, InviteWorkflow, type InviteWorkflowOpts, type IssueResult, type LoginPolicyOverrides, type LoginRedirect, type LoginWfCtx, LoginWorkflow, type LoginWorkflowOpts, type MfaSummary, type MfaTransport, type PreparedUserInput, Public, type RecoveryDeliveryMode, type RecoveryOtpTransport, type RecoveryPolicyOverrides, type RecoveryWfCtx, RecoveryWorkflow, type RecoveryWorkflowOpts, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedInviteWorkflowOpts, type ResolvedLoginWorkflowOpts, type ResolvedRecoveryWorkflowOpts, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, authGuardInterceptor, createAuthEmailOutlet, createAuthShareableLinkOutlet, generateMagicLinkToken, getAuthMate, mergeInviteOpts, mergeLoginOpts, mergeRecoveryOpts, parseInviteRoles, useAuth };