@aooth/auth-moost 0.1.21 → 0.1.22

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
@@ -1178,6 +1178,16 @@ interface AuthWfMfaEnrollState {
1178
1178
  */
1179
1179
  mode?: "required" | "optional" | "manage";
1180
1180
  done?: boolean;
1181
+ /**
1182
+ * Set by `enroll-send` when `resolveEnrollPreConfirmed` vouches that the
1183
+ * staged sms/email address is verified-by-construction (e.g. the invited
1184
+ * email the user just proved by redeeming the magic link delivered to it).
1185
+ * `enroll-send` then skips the pincode dispatch and `enroll-confirm` runs
1186
+ * its write-on-confirm tail with no code-entry pause. Server-only (decided
1187
+ * by the resolver, never read from the wire); never set for TOTP —
1188
+ * possession of an authenticator cannot be proven by construction.
1189
+ */
1190
+ preConfirmed?: boolean;
1181
1191
  /**
1182
1192
  * Gates the standalone `enroll-totp-qr` step (TOTP only). Set once the user
1183
1193
  * has been shown the QR/secret and clicked Continue, so the QR pause fires
@@ -2201,6 +2211,31 @@ declare class AuthWorkflow {
2201
2211
  * Reached from login.flow.
2202
2212
  */
2203
2213
  protected resolveEnrollment(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["enrollment"]> | Promise<NonNullable<AuthWfCtx["enrollment"]>>;
2214
+ /**
2215
+ * Vouch that an MFA-enrolment address is verified-by-construction, skipping
2216
+ * the pincode round-trip: `enroll-send` sends nothing and `enroll-confirm`
2217
+ * writes the confirmed factor (+ `verifiedEmail` for email) with no
2218
+ * code-entry pause. Asked once per dispatch with the staged transport +
2219
+ * normalized address (ctx-first, extra positional args — same convention as
2220
+ * `resolveOtpDisclosure`).
2221
+ *
2222
+ * Default `false` — every enrolment proves its address. The canonical
2223
+ * override is the invite-accept case, where the user is inside the flow
2224
+ * only because they redeemed a magic link delivered to that exact address
2225
+ * minutes earlier (the same proof `activate-user` trusts to write
2226
+ * `account.verifiedEmail`):
2227
+ *
2228
+ * ```ts
2229
+ * protected resolveEnrollPreConfirmed(ctx: AuthWfCtx, method: MfaTransport, address: string) {
2230
+ * return !!ctx.accept && method === "email" && address === ctx.email;
2231
+ * }
2232
+ * ```
2233
+ *
2234
+ * Keep the equality check — the proof transfers ONLY to the address the
2235
+ * magic link was delivered to; vouching for a different address the user
2236
+ * typed would confirm an unproven inbox. Never asked for TOTP.
2237
+ */
2238
+ protected resolveEnrollPreConfirmed(_ctx: AuthWfCtx, _method: MfaTransport, _address: string): boolean | Promise<boolean>;
2204
2239
  /**
2205
2240
  * Resolve the finalize policy. Reached from login.flow. `auditLogin` is
2206
2241
  * dropped from the shape per §2 — audit moved out of the workflow layer.
@@ -2536,10 +2571,18 @@ declare class AuthWorkflow {
2536
2571
  protected mfaKindOf(methodName: string): MfaTransport | null;
2537
2572
  /**
2538
2573
  * Send an enrolment pincode and stamp `ctx.pincode.sentTo` with the masked
2539
- * recipient. Shared by `enrollAddress` (initial dispatch) and the resend
2540
- * path inside `enrollConfirm`.
2574
+ * recipient. Reached only through {@link mintAndSendEnrollPincode}; kept
2575
+ * separate as the delivery-only override seam.
2541
2576
  */
2542
2577
  protected sendEnrollPincode(ctx: AuthWfCtx, address: string, code: string): Promise<void>;
2578
+ /**
2579
+ * The single enrol-dispatch implementation: mint a fresh pin, arm the
2580
+ * resend cooldown + the code-length form hint, and deliver the code.
2581
+ * Shared by `enrollSend` (initial dispatch) and the resend arm inside
2582
+ * `enrollConfirm`, so the arming policy cannot drift between first send
2583
+ * and resend.
2584
+ */
2585
+ private mintAndSendEnrollPincode;
2543
2586
  /**
2544
2587
  * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
2545
2588
  * the pincode timers/`sentTo`) off ctx — the shared teardown used by the
@@ -2952,15 +2995,30 @@ declare class AuthWorkflow {
2952
2995
  */
2953
2996
  enrollPickMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
2954
2997
  /**
2955
- * Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
2956
- * Not invoked for totp. Handles `skip` (opt-in) / `cancel` (manage) /
2957
- * `useDifferentMethod`. Validates the address server-side (the client
2958
- * `@ui.form.validate` hint is advisory), then STAGES the candidate value in
2959
- * wf-state (`m.address`) — the user record is written only on confirm
2960
- * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps the
2961
- * old confirmed value live until the new code verifies in `enroll-confirm`.
2962
- */
2963
- enrollAddress(ctx: AuthWfCtx): Promise<undefined>;
2998
+ * Unified MFA-enrol phase 2 (collect sms/email address). Not invoked for
2999
+ * totp. Handles `skip` (opt-in) / `cancel` (manage) / `useDifferentMethod`.
3000
+ * Validates the address server-side (the client `@ui.form.validate` hint is
3001
+ * advisory), then STAGES the candidate value in wf-state (`m.address`) — the
3002
+ * user record is written only on confirm (write-on-confirm), so an ADD
3003
+ * leaves no partial row and a REPLACE keeps the old confirmed value live
3004
+ * until the new code verifies in `enroll-confirm`. Collection ONLY: the
3005
+ * pincode dispatch lives in `enroll-send` (same engine pass, no extra
3006
+ * round-trip), so a consumer pre-seeding `mfaEnroll.address` — which skips
3007
+ * this whole step via its schema condition — still gets exactly one code.
3008
+ */
3009
+ enrollAddress(ctx: AuthWfCtx): undefined;
3010
+ /**
3011
+ * Unified MFA-enrol dispatch (sms/email only) — the trio's ONLY pincode
3012
+ * send. A separate step (the canonical "send if no pin" gate, mirroring
3013
+ * `pincode-send`) so both address paths share one dispatch site: collected
3014
+ * by `enroll-address`, or pre-seeded by a consumer (which skips
3015
+ * `enroll-address` entirely — previously skipping the dispatch with it and
3016
+ * stranding the user on a code form no code was sent for). Asks
3017
+ * `resolveEnrollPreConfirmed` first: a verified-by-construction address
3018
+ * (e.g. the invite email the magic link just proved) skips the round-trip —
3019
+ * `enroll-confirm` then writes the factor without pausing.
3020
+ */
3021
+ enrollSend(ctx: AuthWfCtx): Promise<undefined>;
2964
3022
  /**
2965
3023
  * MFA-enrol TOTP QR step — shown on its OWN pause between method-pick and
2966
3024
  * code-entry (so the user scans first, types the code next). Idempotently
@@ -2985,6 +3043,16 @@ declare class AuthWorkflow {
2985
3043
  * a fresh row for an ADD.
2986
3044
  */
2987
3045
  enrollConfirm(ctx: AuthWfCtx): Promise<undefined>;
3046
+ /**
3047
+ * `enroll-confirm`'s write-on-confirm tail (uniform for ADD and REPLACE, all
3048
+ * transports): the staged value (sms/email `address`, totp `secret`) is now
3049
+ * proven — by a verified pincode/TOTP code, or vouched by
3050
+ * `resolveEnrollPreConfirmed` — so upsert it as confirmed. `addMfaMethod`
3051
+ * replaces any row of the same name, so a REPLACE atomically swaps in the
3052
+ * new value with no pre-confirm clobber window, and an ADD creates the row
3053
+ * fresh.
3054
+ */
3055
+ private confirmEnrolledFactor;
2988
3056
  /**
2989
3057
  * Promote a freshly-confirmed channel into its login-handle column so future
2990
3058
  * login + recovery resolve the account by it (`findByHandle`). Runs once,
package/dist/index.mjs CHANGED
@@ -797,6 +797,10 @@ const enrollTrioSteps = [
797
797
  id: "enroll-totp-qr",
798
798
  condition: (ctx) => ctx.mfaEnroll?.method === "totp" && !ctx.mfaEnroll.qrSeen
799
799
  },
800
+ {
801
+ id: "enroll-send",
802
+ condition: (ctx) => (ctx.mfaEnroll?.method === "sms" || ctx.mfaEnroll?.method === "email") && !!ctx.mfaEnroll.address && !ctx.mfaEnroll.done && !ctx.mfaEnroll.preConfirmed && !ctx.pin
803
+ },
800
804
  {
801
805
  id: "enroll-confirm",
802
806
  condition: (ctx) => !!ctx.mfaEnroll?.method && (ctx.mfaEnroll.method === "totp" ? !!ctx.mfaEnroll.qrSeen : !!ctx.mfaEnroll.address) && !ctx.mfaEnroll.done
@@ -1414,6 +1418,33 @@ let AuthWorkflow = class AuthWorkflow {
1414
1418
  };
1415
1419
  }
1416
1420
  /**
1421
+ * Vouch that an MFA-enrolment address is verified-by-construction, skipping
1422
+ * the pincode round-trip: `enroll-send` sends nothing and `enroll-confirm`
1423
+ * writes the confirmed factor (+ `verifiedEmail` for email) with no
1424
+ * code-entry pause. Asked once per dispatch with the staged transport +
1425
+ * normalized address (ctx-first, extra positional args — same convention as
1426
+ * `resolveOtpDisclosure`).
1427
+ *
1428
+ * Default `false` — every enrolment proves its address. The canonical
1429
+ * override is the invite-accept case, where the user is inside the flow
1430
+ * only because they redeemed a magic link delivered to that exact address
1431
+ * minutes earlier (the same proof `activate-user` trusts to write
1432
+ * `account.verifiedEmail`):
1433
+ *
1434
+ * ```ts
1435
+ * protected resolveEnrollPreConfirmed(ctx: AuthWfCtx, method: MfaTransport, address: string) {
1436
+ * return !!ctx.accept && method === "email" && address === ctx.email;
1437
+ * }
1438
+ * ```
1439
+ *
1440
+ * Keep the equality check — the proof transfers ONLY to the address the
1441
+ * magic link was delivered to; vouching for a different address the user
1442
+ * typed would confirm an unproven inbox. Never asked for TOTP.
1443
+ */
1444
+ resolveEnrollPreConfirmed(_ctx, _method, _address) {
1445
+ return false;
1446
+ }
1447
+ /**
1417
1448
  * Resolve the finalize policy. Reached from login.flow. `auditLogin` is
1418
1449
  * dropped from the shape per §2 — audit moved out of the workflow layer.
1419
1450
  */
@@ -1968,8 +1999,8 @@ let AuthWorkflow = class AuthWorkflow {
1968
1999
  }
1969
2000
  /**
1970
2001
  * Send an enrolment pincode and stamp `ctx.pincode.sentTo` with the masked
1971
- * recipient. Shared by `enrollAddress` (initial dispatch) and the resend
1972
- * path inside `enrollConfirm`.
2002
+ * recipient. Reached only through {@link mintAndSendEnrollPincode}; kept
2003
+ * separate as the delivery-only override seam.
1973
2004
  */
1974
2005
  async sendEnrollPincode(ctx, address, code) {
1975
2006
  const pincode = ctx.pincode ??= {};
@@ -1984,6 +2015,20 @@ let AuthWorkflow = class AuthWorkflow {
1984
2015
  });
1985
2016
  }
1986
2017
  /**
2018
+ * The single enrol-dispatch implementation: mint a fresh pin, arm the
2019
+ * resend cooldown + the code-length form hint, and deliver the code.
2020
+ * Shared by `enrollSend` (initial dispatch) and the resend arm inside
2021
+ * `enrollConfirm`, so the arming policy cannot drift between first send
2022
+ * and resend.
2023
+ */
2024
+ async mintAndSendEnrollPincode(ctx, address) {
2025
+ const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
2026
+ const pincode = ctx.pincode ??= {};
2027
+ pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
2028
+ pincode.codeLength = this.opts.mfa.pincodeLength;
2029
+ await this.sendEnrollPincode(ctx, address, code);
2030
+ }
2031
+ /**
1987
2032
  * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
1988
2033
  * the pincode timers/`sentTo`) off ctx — the shared teardown used by the
1989
2034
  * opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
@@ -2000,6 +2045,7 @@ let AuthWorkflow = class AuthWorkflow {
2000
2045
  delete m.secret;
2001
2046
  delete m.uri;
2002
2047
  delete m.qrSeen;
2048
+ delete m.preConfirmed;
2003
2049
  }
2004
2050
  delete ctx.pin;
2005
2051
  delete ctx.pinExpire;
@@ -3434,15 +3480,18 @@ let AuthWorkflow = class AuthWorkflow {
3434
3480
  }
3435
3481
  }
3436
3482
  /**
3437
- * Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
3438
- * Not invoked for totp. Handles `skip` (opt-in) / `cancel` (manage) /
3439
- * `useDifferentMethod`. Validates the address server-side (the client
3440
- * `@ui.form.validate` hint is advisory), then STAGES the candidate value in
3441
- * wf-state (`m.address`) — the user record is written only on confirm
3442
- * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps the
3443
- * old confirmed value live until the new code verifies in `enroll-confirm`.
3483
+ * Unified MFA-enrol phase 2 (collect sms/email address). Not invoked for
3484
+ * totp. Handles `skip` (opt-in) / `cancel` (manage) / `useDifferentMethod`.
3485
+ * Validates the address server-side (the client `@ui.form.validate` hint is
3486
+ * advisory), then STAGES the candidate value in wf-state (`m.address`) — the
3487
+ * user record is written only on confirm (write-on-confirm), so an ADD
3488
+ * leaves no partial row and a REPLACE keeps the old confirmed value live
3489
+ * until the new code verifies in `enroll-confirm`. Collection ONLY: the
3490
+ * pincode dispatch lives in `enroll-send` (same engine pass, no extra
3491
+ * round-trip), so a consumer pre-seeding `mfaEnroll.address` — which skips
3492
+ * this whole step via its schema condition — still gets exactly one code.
3444
3493
  */
3445
- async enrollAddress(ctx) {
3494
+ enrollAddress(ctx) {
3446
3495
  this.requireSubject(ctx);
3447
3496
  const m = ctx.mfaEnroll ??= {};
3448
3497
  const mode = m.mode ?? "optional";
@@ -3465,13 +3514,29 @@ let AuthWorkflow = class AuthWorkflow {
3465
3514
  const methodName = m.method;
3466
3515
  const addrErr = this.validateMfaAddress(methodName, input.address);
3467
3516
  if (addrErr) throw this.throwPublic(ctx, wf, { errors: { address: addrErr } });
3468
- const address = this.normalizeMfaAddress(methodName, input.address);
3469
- m.address = address;
3470
- const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
3471
- const pincode = ctx.pincode ??= {};
3472
- pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
3473
- pincode.codeLength = this.opts.mfa.pincodeLength;
3474
- await this.sendEnrollPincode(ctx, address, code);
3517
+ m.address = this.normalizeMfaAddress(methodName, input.address);
3518
+ }
3519
+ /**
3520
+ * Unified MFA-enrol dispatch (sms/email only) — the trio's ONLY pincode
3521
+ * send. A separate step (the canonical "send if no pin" gate, mirroring
3522
+ * `pincode-send`) so both address paths share one dispatch site: collected
3523
+ * by `enroll-address`, or pre-seeded by a consumer (which skips
3524
+ * `enroll-address` entirely — previously skipping the dispatch with it and
3525
+ * stranding the user on a code form no code was sent for). Asks
3526
+ * `resolveEnrollPreConfirmed` first: a verified-by-construction address
3527
+ * (e.g. the invite email the magic link just proved) skips the round-trip —
3528
+ * `enroll-confirm` then writes the factor without pausing.
3529
+ */
3530
+ async enrollSend(ctx) {
3531
+ this.requireSubject(ctx);
3532
+ const m = ctx.mfaEnroll ??= {};
3533
+ const method = m.method;
3534
+ const address = m.address;
3535
+ if (await this.resolveEnrollPreConfirmed(ctx, method, address)) {
3536
+ m.preConfirmed = true;
3537
+ return;
3538
+ }
3539
+ await this.mintAndSendEnrollPincode(ctx, address);
3475
3540
  }
3476
3541
  /**
3477
3542
  * MFA-enrol TOTP QR step — shown on its OWN pause between method-pick and
@@ -3512,8 +3577,11 @@ let AuthWorkflow = class AuthWorkflow {
3512
3577
  */
3513
3578
  async enrollConfirm(ctx) {
3514
3579
  this.requireSubject(ctx);
3515
- const username = ctx.subject;
3516
3580
  const m = ctx.mfaEnroll ??= {};
3581
+ if (m.preConfirmed && m.method !== "totp" && m.address) {
3582
+ await this.confirmEnrolledFactor(ctx);
3583
+ return;
3584
+ }
3517
3585
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollConfirm);
3518
3586
  const action = wf.resolveAction();
3519
3587
  if (this.handleEnrollExit(ctx, action)) return void 0;
@@ -3524,11 +3592,7 @@ let AuthWorkflow = class AuthWorkflow {
3524
3592
  const waitSec = Math.ceil((cooldown - Date.now()) / 1e3);
3525
3593
  throw this.throwPublic(ctx, wf, { formMessage: `Please wait ${waitSec}s before requesting another code` });
3526
3594
  }
3527
- const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
3528
- const pincode = ctx.pincode ??= {};
3529
- pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
3530
- pincode.codeLength = this.opts.mfa.pincodeLength;
3531
- await this.sendEnrollPincode(ctx, m.address, code);
3595
+ await this.mintAndSendEnrollPincode(ctx, m.address);
3532
3596
  return;
3533
3597
  }
3534
3598
  const input = wf.resolveInput();
@@ -3538,8 +3602,23 @@ let AuthWorkflow = class AuthWorkflow {
3538
3602
  const pinErr = this.verifyPin(ctx, input.code);
3539
3603
  if (pinErr) throw this.throwPublic(ctx, wf, { errors: pinErr });
3540
3604
  }
3605
+ await this.confirmEnrolledFactor(ctx);
3606
+ }
3607
+ /**
3608
+ * `enroll-confirm`'s write-on-confirm tail (uniform for ADD and REPLACE, all
3609
+ * transports): the staged value (sms/email `address`, totp `secret`) is now
3610
+ * proven — by a verified pincode/TOTP code, or vouched by
3611
+ * `resolveEnrollPreConfirmed` — so upsert it as confirmed. `addMfaMethod`
3612
+ * replaces any row of the same name, so a REPLACE atomically swaps in the
3613
+ * new value with no pre-confirm clobber window, and an ADD creates the row
3614
+ * fresh.
3615
+ */
3616
+ async confirmEnrolledFactor(ctx) {
3617
+ this.requireSubject(ctx);
3618
+ const username = ctx.subject;
3619
+ const m = ctx.mfaEnroll ??= {};
3541
3620
  const methodName = m.method;
3542
- const value = m.method === "totp" ? m.secret : m.address;
3621
+ const value = methodName === "totp" ? m.secret : m.address;
3543
3622
  await this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
3544
3623
  name: methodName,
3545
3624
  value,
@@ -5077,8 +5156,16 @@ __decorate([
5077
5156
  __decorateParam(0, WorkflowParam("context")),
5078
5157
  __decorateMetadata("design:type", Function),
5079
5158
  __decorateMetadata("design:paramtypes", [Object]),
5080
- __decorateMetadata("design:returntype", Promise)
5159
+ __decorateMetadata("design:returntype", void 0)
5081
5160
  ], AuthWorkflow.prototype, "enrollAddress", null);
5161
+ __decorate([
5162
+ Step("enroll-send"),
5163
+ Public(),
5164
+ __decorateParam(0, WorkflowParam("context")),
5165
+ __decorateMetadata("design:type", Function),
5166
+ __decorateMetadata("design:paramtypes", [Object]),
5167
+ __decorateMetadata("design:returntype", Promise)
5168
+ ], AuthWorkflow.prototype, "enrollSend", null);
5082
5169
  __decorate([
5083
5170
  Step("enroll-totp-qr"),
5084
5171
  Public(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth-moost",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Moost auth integration for aoothjs — AuthGuard interceptor, useAuth composable, REST endpoints, workflows",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -59,10 +59,10 @@
59
59
  "dependencies": {
60
60
  "@atscript/moost-wf": "^0.1.100",
61
61
  "@wooksjs/http-body": "^0.7.19",
62
- "@aooth/arbac-moost": "^0.1.21",
63
- "@aooth/auth": "0.1.21",
64
- "@aooth/idp": "0.1.21",
65
- "@aooth/user": "0.1.21"
62
+ "@aooth/auth": "0.1.22",
63
+ "@aooth/arbac-moost": "^0.1.22",
64
+ "@aooth/user": "0.1.22",
65
+ "@aooth/idp": "0.1.22"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@atscript/core": "^0.1.76",