Skip to content

After a gateway restart, a patient's submit fails with "HTTP Request Failed 403" and the only way to re-verify discards their answers #1758

Open

Description

@joshunrau

If the gateway restarts (a deploy, a crash, or docker compose up pulling a new image) while a patient is filling in an instrument, the patient can no longer submit and is given no way to recover without losing their answers. The human-verification set is in memory by design, and apps/gateway/AGENTS.md says "a restart just makes the patient re-verify". But the page never offers re-verification. The Cap widget is rendered only as beforeBegin on the overview step, and Assignment's verified state stays true on the client. After the restart, every PATCH /api/assignments/:id is refused with 403 "Assignment has not passed human verification". The axios interceptor turns that into a generic "HTTP Request Failed" toast titled "403", and pressing Submit again fails the same way. Reloading is the only way to see the widget again, and it discards the form's answers. For an interactive instrument, the task the patient has just finished has to be done again from the start.

Where

apps/gateway/src/routers/api.router.ts:102-104:

if (!isAssignmentVerified(id)) {
  throw new HttpException(403, 'Assignment has not passed human verification');
}

apps/gateway/src/Root.tsx:40 and :74-91. verified is never reset, the PATCH failure is not handled, and the widget lives only on the overview step:

const [verified, setVerified] = useState(false);
// ...
await axios.patch(`/api/assignments/${id}`, updateData, {
  headers: {
    Authorization: `Bearer ${token}`
  }
});
// ...
<InstrumentRenderer
  disableSummaryActions
  beforeBegin={<CapWidget onSolve={(token) => void handleSolve(token)} />}

Reproduce

  1. Open an assignment link, verify, click Begin, and answer the form.
  2. Restart the gateway process (for example docker compose restart gateway, or stop and start pnpm dev for the gateway).
  3. Click Submit, then click it again.

Actual: each click shows "HTTP Request Failed" / "403", and nothing on the page explains it or offers the verification again. The only recovery is to reload, which clears every answer and returns to the overview.
Expected: the page tells the patient (translated) that it needs to confirm they are human again, shows the widget in place without leaving the content step, and resubmits the same answers once the challenge is solved.

Tests

  • apps/gateway/src/__tests__/Root.test.tsx: it('should ask the patient to verify again when a submission is refused for lack of verification, and resubmit the same answers once they do').
  • apps/gateway/src/routers/__tests__/api.router.test.ts: it('should mark a refusal for missing verification distinctly, so the page can tell it from other 403s').
  • e2e, testing/src/specs/gateway-assignment.spec.ts: after Begin, use gatewayPage.route to answer the first PATCH with the gateway's verification-required 403. Assert that the re-verification prompt appears and that the answers are still on the form.

Suggested fix

Have the PATCH return a machine-readable reason for this case, for example { code: 'VERIFICATION_REQUIRED', statusCode: 403 }. In Assignment, catch that response: set verified to false, keep the pending submission, and render CapWidget in a dialog with a translated explanation. After handleSolve succeeds, replay the kept submission. Leave the global toast for other errors.

Activity

  1. added
    Priority: LowNice to have, cosmetic or speculative
    EnhancementType: an improvement to an existing feature, including performance
    Difficulty: MediumStraightforward change, about a day or less
    Area: Gatewayapps/gateway (patient-facing app for remote assignments)
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e).

    The replay has to happen inside the pending handleSubmit call

    Replaying the kept submission later from handleSolve would save the answers but leave the patient on the content step. The renderers move on only when onSubmit resolves (ScalarInstrumentRenderer.tsx:82-88, SeriesInstrumentRenderer.tsx:99-118), and nothing on the page can send the result again. InteractiveContent forwards the instrument's done through one voided call (packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx:76-84). A form with resetButton is already empty by the time the 403 arrives (#1673). So on VERIFICATION_REQUIRED, handleSubmit should open the dialog, await a promise that handleSolve resolves once /api/auth/verify succeeds, re-send the same updateData, and return only when that succeeds.

    Gotchas:

    For the toast, copying apps/web's meta.disableDefaultErrorNotification (apps/web/src/services/axios.ts:18, :163-165) would leave Root.tsx to raise the generic toast itself for every other failure. Let the caller name the codes it handles instead, so the gateway interceptor (apps/gateway/src/services/axios.ts:16-43) still reports everything else. #1750 and #1703 add refusals Root.tsx must also tell apart, so build the code (on HttpException, in errorHandlerMiddleware's body, in that option) once, in whichever lands first. apps/gateway/AGENTS.md:54 ("Throw HttpException(status, message)") changes in the same PR.

    Alternative: make verification survive a restart

    /api/auth/verify could return an HMAC of the assignment id keyed on config.apiKey, using the crypto that src/utils/auth.ts already imports (no new dependency). The page sends it with each PATCH, and the handler checks it instead of calling isAssignmentVerified.

    • After a restart, submits still succeed. No dialog, no replay, no toast opt-out, no second proof of work.
    • /verify can look up the row first and refuse an unknown or completed id. Today it stores any id that passes proof of work (cap.router.ts:58-62), and nothing evicts an id that is never completed, as cap.router.ts:15-19 notes. That growth goes away with the set.
    • Cost: it reverses the decision at apps/gateway/AGENTS.md:59-61 and :76-79. It also drops clearAssignmentVerification on COMPLETE (api.router.ts:151-153), which today is what refuses a second submit after completion.

    Recommendation: take this route. It is less code than the dialog, and it removes the failure instead of handling it.

    Ordering

    Either way, #1750's completedAt guard lands first or in the same PR. With the dialog, a second tab re-verifies after completion, and its SCALAR PATCH overwrites the completed encryptedData before the sync. With signing, a signature issued before COMPLETE stays valid after it. For the dialog, land #1715 first: it translates Root.tsx:49 and :55 and the Cap labels the dialog reuses. #1535 is the same problem in apps/web.

    Tests, beyond those in the issue

    • Dialog, Root.test.tsx: onSubmit stays pending until the dialog's widget is solved, then patch is called again with the same body. A 403 without the code is not replayed. The mocked InstrumentRenderer always renders beforeBegin, and solveChallenge takes the first cap-widget, so query inside the dialog.
    • Dialog, axios.test.ts and error-handler.middleware.test.ts: a named code raises no toast while other errors still do, and the body carries the code.
    • Dialog, e2e: answer the first PATCH with gatewayPage.route(..., { times: 1 }), solve the dialog's widget, and assert the summary heading appears without answering again. That is two proofs of work, and the first alone is allowed 120 s (testing/src/specs/gateway-assignment.spec.ts:88), so the test needs more than the describe's 180 s (:65).
    • Signing: api.router.test.ts accepts a valid signature and refuses a forged one, cap.router.test.ts refuses an unknown or completed id, and Root.test.tsx sends the signature /verify returned. The e2e sends a tampered signature through apiRequestContext, like the malformed-submission test, and expects 403.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Gatewayapps/gateway (patient-facing app for remote assignments)Difficulty: MediumStraightforward change, about a day or lessEnhancementType: an improvement to an existing feature, including performancePriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions