FuzeFront

Approval frames · feature: signup-phone-verification · FFRNT-361

Phone verification by text message, as a step of sign-up

After an account is created, the user can confirm a mobile number with a 6-digit text-message code, before going on to the existing "check your inbox" step. These frames show the placement, the happy path, and every state the implementation must render: wrong, expired and locked codes, send failures, rate limits, no SMS channel in this environment, a number that can't be used, both skip policies, loading, resume, session and generic errors. Each is shown on desktop and at 375px. Approving a flow approves the component and package plan below. No UI exists yet.

01 · placement 02 · /signup/phone 03 · /signup/phone 04 · /signup/phone 05 · fail-closed 06 · fail-closed 07 · policy 08 · states

Decisions these frames freeze

Build inventory approve = approve this plan

Flows (orchestrators)

  • SignupPhoneVerificationFlow → /signup/phone pending

Components

  • SignupPhoneVerificationFlow new
  • ContactVerifyPanel shared w/ mfa-stepup-mobile
  • PhoneNumberField new
  • OtpInput reuse
  • ResendCooldownButton reuse · auth-experience
  • SignupProgress new
  • AuthCard reuse · auth-experience

Packages

  • @fuzefront/auth-ui (extend)
  • implementation: packages/auth-ui/src/components/phone-verification/**
  • consumes @fuzefront/security-client
  • consumes libphonenumber-js (MIT, E.164 + per-country length. Adopted, not hand-rolled)

DS primitives to add first frontend-engineer

  • OtpInput (44×52 cells). Already named as missing by mfa-management and mfa-stepup-mobile; build it once
  • PhoneInput / CountryCodeSelect (flag + dial code, 44px target)
  • Alert info/warning variants (if still missing)

Contract binding & deltas

OpenAPI packages/security/openapi.yaml → @fuzefront/security-client · flag fuzefront.auth.signup-phone-verification (default OFF, builders audience) · endpoints GET /v1/security/methods, GET /v1/security/verify/status, POST /v1/security/verify/phone/start, POST /v1/security/verify/phone/resend (new, Δ), POST /v1/security/verify/phone/confirm.

AuthMethods does advertise SMS availability: verification.sms, derived from SMS_SERVICE_URL in GET /methods. The step relies on it to decide whether to appear. The contract today is not enough to render these frames honestly. The deltas below are for contract-designer → backend-engineer:

  1. security POST /verify/phone/confirm must require bearerAuth and bind to the session user. Today the route has no requireBearer, and the provider runs users.where({ phone }).update({ phone_verified: true }), which marks every account with that number as verified.
  2. /start must not write users.phone before the user proves they have the phone. Today the provider writes users.phone and only then awaits sendSmsOtp. It should hold a pending number, promote it on confirm, and accept a confirm body of { code }. The pending challenge, and so phonePending, is committed only after the SMS provider accepts the send. On 502, 503 or 429 it is rolled back, so a reload never resumes at code entry when no code was sent.
  3. security Every ?next goes through one safeReturnPath() guard, following LoginPage's allowlist approach: a single leading / (never //, never a scheme), same origin, normalized, and on the allowlist of app routes. Anything else goes to the default app. Without it, ?next=//evil.example is an open redirect straight after sign-in.
  4. 202 body PhoneVerifyStartAck { maskedPhone, expiresInSeconds, resendAfterSeconds }. On a resend, expiresInSeconds is the code's actual remaining lifetime. The provider may re-send the same unexpired code without extending it.
  5. /start errors: 400 PHONE_INVALID · 429 RATE_LIMITED+Retry-After (no limiter exists today) · 502 SMS_SEND_FAILED · 503 SMS_UNAVAILABLE. Today these fall through to 401 UNKNOWN "Authentication failed", so a failed send looks like a lost session.
  6. /confirm errors: 422 CODE_INVALID {attemptsRemaining} · 422 CODE_EXPIRED · 429 TOO_MANY_ATTEMPTS+Retry-After · 409 PHONE_UNAVAILABLE (only after a correct code). Today all of these come back as 401 NOT_ACTIVE.
  7. /start returns 202 for every well-formed number, whether or not another account already uses it.
  8. AuthMethods.verification.phoneAtSignup: "off" | "optional" | "required".
  9. VerificationStatus.phonePending?: { maskedPhone, expiresInSeconds, resendAfterSeconds } so the step can resume after a reload. Only the masked number ever comes back to the client.
  10. New POST /verify/phone/resend: session-bound with an empty body. It resends to the pending number the server holds, so Resend still works after a reload, when the client knows only the masked number. Returns PhoneVerifyStartAck; 404 NO_PENDING_VERIFICATION sends the user back to enter-phone. /start remains the only call that takes a full number.
  11. Confirm-time outage: when sms-service /sms/verify times out or returns non-2xx, the result is 503 VERIFY_UNAVAILABLE. It doesn't use an attempt and isn't reported as a wrong code. Today checkSmsOtp turns every failure into false, and /sms/verify returns only a boolean. It should return the provider status, and the client should get a tagged result.
  12. Rate-limit pass-through: sms-service /sms/send returns 429 with no Retry-After, and sendSmsOtp discards the response. Both must carry retryAfterSeconds through to 429 RATE_LIMITED + Retry-After on /start and /resend.
  13. Routing: when the step applies, 01-signup's submit goes to /signup/phone. Otherwise it goes through nextAfterPhone(): /signup/verify only if an email is pending, else the app. Behind the flag; no approved frame changes.
  14. Routing: after a successful sign-in in auth-experience's signin flow, redirect to /signup/phone?next=… when phoneAtSignup === "required", phoneVerified is false and SMS is available. Behind the flag; no approved frame changes.

Reconciliation with existing frames

Open questions for the owner

Status: ● awaiting review. The flow is approved: false. No UI, tests, or deploy exist yet; merging the approved frames is the gate that starts them. Re-stamp after any edit: node scripts/stamp-frames.mjs --write --feature signup-phone-verification.

Review · signup-phone-verification approve or reject each flow ↓
signup-phonependingApproveReject