Repository navigation
Description
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
- Create a remote assignment with an expiry of tomorrow.
- Advance the clock past
expiresAt, or set the gateway row'sexpiresAtto the past in its SQLite database. - 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.tsand 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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touched the synchronizer, the gateway routers orassignments.service.tssince this was filed, and no open PR touches them.Let the gateway decide expiry
sync()already handles a remoteEXPIREDstatus. Theelsebranch atgateway.synchronizer.ts:91-94deletes the remote row and records the status, and the setup-state test atgateway.synchronizer.test.ts:157-167runs that path. The gateway just never reportsEXPIRED. So I'd write the rule once, in the gateway, rather than add anexpiresAtcheck tosync():apps/gateway/src/lib/assignment-status.tswithresolveAssignmentStatus(row). It returnsEXPIREDfor anOUTSTANDINGrow withexpiresAt <= now, and the stored status otherwise.GET /api/assignments(api.router.ts:25-38) returns that status instead of casting the column. The API then deletes the row and recordsEXPIREDthrough the existingelsebranch.GET /assignments/:id(root.router.ts:20-27) andPATCH /api/assignments/:id(after the lookup atapi.router.ts:106-111) refuse the row with 410. Keep the completed check first, so a patient who finished just before expiry still gets 409. In the PATCH, the completed check is the one Gateway appends series answers without checking which item they answer, so a second tab locks the patient out or makes the series unimportable #1750 proposes. Until Reopening a completed or removed assignment link shows raw JSON or Express's "Cannot GET" page instead of a translated notice #1710 lands, the 410 page is raw JSON like today's 409.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 hidingexpiresAt.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 forOUTSTANDING(apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:57), but the API never checks. Refuse anything that is notOUTSTANDINGand unexpired.410, from the interceptor inapps/gateway/src/services/axios.ts. Give 410 its ownt({ en, es, fr })message in that interceptor. It fires for every error, so a notification added inRoot.tsx'shandleSubmitwould make two toasts.Decisions
OUTSTANDINGwith its answered items already inencryptedData, so deleting it at expiry discards them, a consent form included. Importing them would need a stand-in forcompletedAt:handleAssignmentCompleteaccepts a prefix of the series (gateway.synchronizer.ts:133-143) but stops without one (:110-112), and the gateway sets it only onCOMPLETE(api.router.ts:142). Discarding is the simplest reading of "expired". If you choose it, log the id and the number of entries discarded.Tests
apps/gateway/src/lib/__tests__/assignment-status.test.ts: an unexpired outstanding row staysOUTSTANDING, a past-expiry one becomesEXPIRED, and aCOMPLETErow staysCOMPLETEafter its expiry.api.router.test.ts:GET /assignmentsreportsEXPIRED, 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, theGET /assignmentsrow) has anexpiresAt, so give them a future one first.gateway.synchronizer.test.ts: a namedEXPIREDcase next to theCANCELEDone (:187), plus the discard log if you add it.assignments.controller.test.ts: emailing an expired or completed assignment is refused. Its sharedassignmentfixture has nostatusand expires on 2026-08-01, already past, so it needsstatus: 'OUTSTANDING'and a future expiry or every existingsendEmailtest fails.services/__tests__/axios.test.ts: a 410 shows the expired message.testing/src/specs/gateway-assignment.spec.ts: the suite can't write to the gateway's SQLite and$FutureDaterejects a past expiry, so seed one about 15 s out and wait.seedAssignmentcomputesexpiresAtbefore its twoawaits; 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/verifynever 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 pollGET /api/v1/assignments?subjectId=until it reportsEXPIRED, with noREMOTEsession for the subject.Triage note written with Claude Code. Code references point at
main@d86740e.