Skip to content

Remote assignments never expire: the gateway accepts submissions after expiresAt and nothing marks an assignment EXPIRED #1703

Open

Description

@joshunrau

Remote assignments never expire. Every assignment stores an expiresAt (required, validated to be in the future, and shown to clinicians and in the email), but no code compares it to the current time after creation. The gateway serves the assignment page and accepts its submission at any later date. The API's GatewaySynchronizer skips every remote OUTSTANDING assignment without looking at expiresAt, and then imports the submission when it arrives. AssignmentStatus declares EXPIRED, deleteBulk filters on it and the web formats it, but no code path ever writes it (git grep EXPIRED in apps/api/src and apps/gateway/src finds only that filter). As a result, a link a clinician believes lapsed months ago still collects data and creates a record. The assignment also stays OUTSTANDING in the subject's Assignments tab indefinitely, and its encrypted keypair and instrument bundle are never removed from the gateway. The duplicate check in resolveBulkRequest already treats a past-expiresAt assignment as dead (expiresAt: { gt: new Date() }). It therefore lets a group re-assign the same instrument while the old link still works, which gives the participant two live links.

Where

apps/api/src/gateway/gateway.synchronizer.ts:83-94: an outstanding assignment is skipped whatever its expiry:

for (const assignment of remoteAssignments) {
  try {
    if (assignment.status === 'OUTSTANDING') {
      continue;
    } else if (assignment.status === 'COMPLETE') {
      await this.handleAssignmentComplete(assignment);
    } else {
      await this.gatewayService.deleteRemoteAssignment(assignment.id);
    }
    await this.assignmentsService.updateStatusById(assignment.id, assignment.status);

apps/gateway/src/routers/root.router.ts:17-27: the page is refused only once it is completed:

const assignment = await prisma.remoteAssignmentModel.findFirst({ where: { id } });
if (!assignment) {
  return next();
} else if (assignment.completedAt) {
  return res.status(409) /* ... 'Assignment already completed' */;
}

apps/gateway/src/routers/api.router.ts:97-150 (PATCH /api/assignments/:id) likewise accepts a submission without checking expiresAt.

Reproduce

  1. Create a remote assignment with an expiry of tomorrow.
  2. Advance the clock past expiresAt, or set the gateway row's expiresAt to the past in its SQLite database.
  3. Open the assignment URL, complete the instrument, and wait one GATEWAY_REFRESH_INTERVAL.

Actual: the page renders, the submission is accepted, the synchronizer creates a session and a record, and the assignment becomes COMPLETE. Before submission, the assignment shows OUTSTANDING for as long as the instance runs.
Expected: once expiresAt passes, the gateway refuses the page and the submission. The synchronizer deletes the remote assignment and marks the local one EXPIRED.

Tests

  • apps/api/src/gateway/__tests__/gateway.synchronizer.test.ts: it('should delete an outstanding remote assignment past its expiry and mark it EXPIRED, so a lapsed link stops collecting data'), plus a case that an unexpired outstanding assignment is still left alone.
  • apps/gateway/src/routers/__tests__/root.router.test.ts and the api router tests: it('should refuse an assignment whose expiry has passed').
  • testing/src/specs/gateway-assignment.spec.ts: create an assignment, move its expiry into the past on the gateway, and assert that the link is refused and the assignment shows as expired in the subject's Assignments tab.

Suggested fix

In sync(), treat a remote OUTSTANDING assignment with expiresAt <= now like a cancelled one: delete it from the gateway, then updateStatusById(id, 'EXPIRED'). In the gateway, refuse GET /assignments/:id and PATCH /api/assignments/:id once expiresAt has passed. That check covers the window before the next sync pass.

Activity

  1. added
    BugType: existing behavior is wrong
    Priority: HighSignificant or frequently hit user impact; target the next release or two
    Difficulty: MediumStraightforward change, about a day or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    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). Nothing has touched the synchronizer, the gateway routers or assignments.service.ts since this was filed, and no open PR touches them.

    Let the gateway decide expiry

    sync() already handles a remote EXPIRED status. The else branch at gateway.synchronizer.ts:91-94 deletes the remote row and records the status, and the setup-state test at gateway.synchronizer.test.ts:157-167 runs that path. The gateway just never reports EXPIRED. So I'd write the rule once, in the gateway, rather than add an expiresAt check to sync():

    One clock then decides both when submissions stop and when the row is deleted. With the check in sync(), an API clock running ahead deletes links the gateway still serves. It also lets #1733 filter the listing to the rows the API must act on (OR: [{ status: { not: 'OUTSTANDING' } }, { expiresAt: { lte: now } }]) without hiding expiresAt.

    Also affected

    • POST /v1/assignments/:id/email (assignments.controller.ts:106-153) mails the link whatever the assignment's status or expiry. The web only shows the form for OUTSTANDING (apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:57), but the API never checks. Refuse anything that is not OUTSTANDING and unexpired.
    • A refused PATCH reaches the patient as the generic "HTTP Request Failed" toast titled 410, from the interceptor in apps/gateway/src/services/axios.ts. Give 410 its own t({ en, es, fr }) message in that interceptor. It fires for every error, so a notification added in Root.tsx's handleSubmit would make two toasts.

    Decisions

    Tests

    • apps/gateway/src/lib/__tests__/assignment-status.test.ts: an unexpired outstanding row stays OUTSTANDING, a past-expiry one becomes EXPIRED, and a COMPLETE row stays COMPLETE after its expiry.
    • api.router.test.ts: GET /assignments reports EXPIRED, and the PATCH refuses with 410 and writes nothing. root.router.test.ts: an expired row gets 410, and a completed-and-expired one still gets 409. None of the fixtures in these two files (storeAssignment, createStoredAssignment, the GET /assignments row) has an expiresAt, so give them a future one first.
    • gateway.synchronizer.test.ts: a named EXPIRED case next to the CANCELED one (:187), plus the discard log if you add it.
    • assignments.controller.test.ts: emailing an expired or completed assignment is refused. Its shared assignment fixture has no status and expires on 2026-08-01, already past, so it needs status: 'OUTSTANDING' and a future expiry or every existing sendEmail test fails.
    • services/__tests__/axios.test.ts: a 410 shows the expired message.
    • e2e, testing/src/specs/gateway-assignment.spec.ts: the suite can't write to the gateway's SQLite and $FutureDate rejects a past expiry, so seed one about 15 s out and wait. seedAssignment computes expiresAt before its two awaits; compute a short one after them, or the create call can fail validation on a slow runner. Open the link at once and solve Cap. /api/auth/verify never looks at the assignment (cap.router.ts:51-65), so only the page load must land before expiry. Then assert that the link and a PATCH with the page's token both answer 410 or 404 (they race the sync, as in the smoke test), that the email endpoint refuses, and poll GET /api/v1/assignments?subjectId= until it reports EXPIRED, with no REMOTE session for the subject.

    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: APIapps/api (NestJS backend, Prisma models, CASL permissions)Area: Gatewayapps/gateway (patient-facing app for remote assignments)BugType: existing behavior is wrongDifficulty: MediumStraightforward change, about a day or lessPriority: HighSignificant or frequently hit user impact; target the next release or two

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions