W30 — Live Verification Gate — Ticket #3269 (Forgot Password Flow, mobile+OTP) ================================================================================ VERDICT: FAILED — deterministic production build failure. The application cannot be built or served in a usable state once any page that imports this ticket's own Server Action module is requested; the failure additionally takes down the pre-existing /login page in the same dev-server process. -------------------------------------------------------------------------------- 1. ENVIRONMENT / SETUP -------------------------------------------------------------------------------- - Repo: oms-web (Next.js 14.2.35, App Router) - DATABASE_URL: postgresql://oms_test:***@localhost:5432/oms_test?schema=public (real local Postgres, not mocked) - `npx prisma migrate status` on entry showed 3 unapplied migrations, including this ticket's own 20260904000000_password_reset_flow. Applied via `npx prisma migrate deploy` (all 3 applied cleanly, `PasswordResetFlow` table confirmed present with expected columns/indexes/FK/check-constraint). - `npx prisma generate` run after migrating (Prisma Client v5.20 -> v5.22 already installed; regenerated to pick up the new PasswordResetFlow model). - Seeded via `SUPER_ADMIN_MOBILE=9998887776 npm run seed` (prisma/seed.ts, upsert-based, deterministic). Confirmed row: username=superadmin mobile=9998887776 isActive=t (SUPER_ADMIN_USERNAME=superadmin / SUPER_ADMIN_PASSWORD=SuperAdminPw123! from the checked-out .env — not invented for this run). -------------------------------------------------------------------------------- 2. BUILD FAILURE — root cause, reproduced 2x via `next build`, 2x via `next dev` -------------------------------------------------------------------------------- `npm run build` (next build, production mode): ./lib/actions/forgot-password.ts Error: x Only async functions are allowed to be exported in a "use server" file. ,-[lib/actions/forgot-password.ts:49:1] 49 | // Constants (must stay in sync with lib/queries/forgot-password.ts) 52 | export const RESET_FLOW_COOKIE = "oms_reset_flow"; `---- Import trace for requested module: ./lib/actions/forgot-password.ts ./components/auth/ForgotPasswordForm.tsx (second, identical error via ./lib/forgot-password/index.ts -> app/(auth)/reset-password/page.tsx) > Build failed because of webpack errors Root cause: `lib/actions/forgot-password.ts` starts with `"use server"` (making every export in the file a Server Action reference), but line 52 exports a plain string constant, `RESET_FLOW_COOKIE`. Next.js's "use server" file convention requires every export to be an async function — this is a hard compiler-enforced rule, not a lint nit. `lib/forgot-password/index.ts` (the ticket's own barrel file) re-exports that same constant, and it is imported by `ForgotPasswordForm.tsx`, `VerifyOtpForm.tsx` (via the barrel), and both the verify-otp/reset-password Server Component pages (for the cookie name). Ran `rm -rf .next && npm run build` a second time: identical error, identical line number, identical trace. Deterministic, not a flake. -------------------------------------------------------------------------------- 3. LIVE SERVER BEHAVIOR (`next dev`, port 3101, fresh `.next`) -------------------------------------------------------------------------------- Sequence (curl, real HTTP, real dev server): GET /login -> 200 (compiled cleanly, in isolation) GET /forgot-password -> 500 (webpack compile error above, in-band) GET /login (retried) -> 500 (SAME login page that was 200 seconds ago) GET /verify-otp -> 500 (same compile error) GET /reset-password -> 500 (same compile error) GET /forgot-password (again) -> 500 (25ms — cached failed-compilation state) GET /api/nonexistent -> 401 (middleware's own auth gate still runs — confirms the process itself is alive, this isn't a crashed server, it's a poisoned webpack compilation) Repeated the whole sequence on a second, completely fresh dev-server process (new port, `rm -rf .next` again): identical results — /login 200 first, then 500 for every route (including /login) the instant /forgot-password is hit. This means ticket #3269's own diff does not just fail to deliver its own feature — it also regresses AC-48e7fa93 ("Existing login ... flows continue to work unchanged"): a real user who visits /login AFTER anyone (or any bot/ crawler) has hit /forgot-password on the same running server gets a 500, through no fault of their own login attempt. This was confirmed live, not inferred from the build log alone. -------------------------------------------------------------------------------- 4. STATIC REVIEW OF THE 4 DECLARED SERVER ACTIONS (code-read only — none of these could be exercised end-to-end through a live HTTP round trip because every page that would surface their form/action reference 500s; see 3, 5) -------------------------------------------------------------------------------- lib/actions/forgot-password.ts declares 4 Server Actions: - requestResetCode (Step 1 — send OTP) - resendResetCode (Step 1b — resend, 60s cooldown via lastSentAt) - verifyResetCode (Step 2 — check OTP, 5-attempt lockout -> row delete) - completeReset (Step 3 — set new password, revoke sessions, consume flow) None of the 4 are permission-gated (by design — this is the unauthenticated recovery path; middleware.ts's matcher explicitly excludes these routes from the session gate). permission_gated = 0. Logic read (OTP hashing/expiry/reuse/history/session-revocation) looks correctly implemented on paper — SHA-256 hash storage, timing-safe compare, expiresAt/consumedAt/verifiedAt state machine, MAX_ATTEMPTS=5 hard-delete, PasswordHistory last-3 reuse check, full session revocation on reset. This could not be confirmed by actually running the flow since no page hosting these forms will render. -------------------------------------------------------------------------------- 5. ACCEPTANCE CRITERIA — additional FAILs found by static/code inspection (independent of the build failure, and would still fail even if the build were fixed) -------------------------------------------------------------------------------- - AC-c863f09c / AC-9f181d54 (Sign In "Forgot Password?" link + navigation): FAILED. `components/auth/SignInForm.tsx` (the Sign In screen's actual form) has no "Forgot Password" link or any reference to /forgot-password at all. `grep -rn "Forgot Password|forgot-password" app` only matches the forgot-password/verify-otp/reset-password pages themselves — nothing in app/(auth)/login/page.tsx or SignInForm.tsx. There is no way to reach this flow from the Sign In screen as built. - AC-856f6630 (six individual digit boxes): FAILED. `VerifyOtpForm.tsx` renders ONE `` text field (letter-spaced via CSS `tracking-[0.5em]` to visually approximate six boxes), not six discrete input elements. The ticket's own requirement text explicitly says "six individual digit boxes, matching the design." - AC-fe7ba705 (Password Reset Success screen/state): FAILED. `completeReset` ends with a bare server-side `redirect("/login")` — no query param, no flash/toast state, and `app/(auth)/login/page.tsx` / `SignInForm.tsx` read no search param and render no success message. A user is silently dropped back at a blank Sign In form with zero confirmation the reset worked. - AC-fc35922f (rate limiting on request AND resend endpoints): PARTIAL FAIL. `resendResetCode` has a real 60s cooldown (`RESEND_COOLDOWN_MS`, enforced server-side via `lastSentAt`). `requestResetCode` (the initial "Send Reset Code" submission) has no rate limiting or cooldown at all — grepped the whole repo for rate-limit-related identifiers, only match is inside forgot-password.ts itself (the resend cooldown). A caller can invoke requestResetCode unlimited times per minute for the same mobile number, each call doing a delete+insert+(no-op SMS stub) with no throttle. These are independent, additional findings layered on top of the build failure in section 2 — even a hypothetical fixed build would still fail AC-c863f09c, AC-9f181d54, AC-856f6630, AC-fe7ba705, and partially AC-fc35922f. -------------------------------------------------------------------------------- 6. WHAT COULD NOT BE VERIFIED -------------------------------------------------------------------------------- Every AC that requires actually submitting a form and observing a live response (valid/invalid/expired/reused OTP, resend timer live countdown, password policy enforcement round-trip, password mismatch inline error, successful reset, login with the new password, unregistered mobile handling, network-failure UI resilience, refresh/back-navigation mid-flow) could NOT be exercised — every page in this flow, and (transitively, once touched) /login itself, returns HTTP 500 from the real Next.js compiler, before any React tree or form ever renders. This is not a scope limitation of this verification run; it is a direct, structural consequence of the build failure in section 2. -------------------------------------------------------------------------------- 7. RETRIES -------------------------------------------------------------------------------- - `next build`: run twice (fresh `.next` both times) — identical error, identical file/line, both times. - `next dev`: run as two separate fresh server processes (different ports, fresh `.next` both times) — identical 500 cascade, including /login breaking after /forgot-password was hit, both times. Classification: deterministic (not flake_suspected). Same root cause, same file, same line, 4/4 runs.