|
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 `<input maxLength={6}>` 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.
|