Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions apps/api/src/gateway/gateway.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { webcrypto } from 'node:crypto';
import { HybridCrypto } from '@douglasneuroinformatics/libcrypto';
import { LoggingService } from '@douglasneuroinformatics/libnest';
import { HttpService } from '@nestjs/axios';
import { BadGatewayException, HttpStatus, Injectable } from '@nestjs/common';
import { BadGatewayException, HttpStatus, Injectable, InternalServerErrorException } from '@nestjs/common';
import { $MutateAssignmentResponseBody, $RemoteAssignment } from '@opendatacapture/schemas/assignment';
import type {
Assignment,
Expand All @@ -12,31 +12,29 @@ import type {
RemoteAssignment
} from '@opendatacapture/schemas/assignment';
import { $GatewayHealthcheckSuccessResult } from '@opendatacapture/schemas/gateway';
import type { GatewayHealthcheckFailureResult, GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway';
import type {
GatewayHealthcheckFailureResult,
GatewayHealthcheckResult,
RemoteSetupState
} from '@opendatacapture/schemas/gateway';

import { InstrumentsService } from '@/instruments/instruments.service';
import { SetupService } from '@/setup/setup.service';

@Injectable()
export class GatewayService {
constructor(
private readonly httpService: HttpService,
private readonly instrumentsService: InstrumentsService,
private readonly loggingService: LoggingService,
private readonly setupService: SetupService
private readonly loggingService: LoggingService
) {}

async createRemoteAssignment(
assignment: Assignment,
publicKey: webcrypto.CryptoKey
): Promise<MutateAssignmentResponseBody> {
const instrument = await this.instrumentsService.findBundleById(assignment.instrumentId);
// The gateway cannot read this instance's setup state, so the languages it may offer this
// patient are sent with the assignment.
const { activeLanguages } = await this.setupService.getState();
const response = await this.httpService.axiosRef.post(`/api/assignments`, {
...assignment,
activeLanguages,
instrumentContainer: instrument,
publicKey: Array.from(await HybridCrypto.serializePublicKey(publicKey))
} satisfies CreateRemoteAssignmentInputData);
Expand Down Expand Up @@ -106,4 +104,19 @@ export class GatewayService {
}
return result.data;
}

/**
* Replace the setup state held by the gateway. The gateway cannot reach this instance, so it
* learns of a change only by being told; it is told on every synchronization pass rather than
* only when the state changes, which is what lets a gateway that missed the change — restarting,
* unreachable, redeployed with an empty in-memory copy — catch up on its own.
*/
async updateRemoteSetupState(setupState: RemoteSetupState): Promise<void> {
const response = await this.httpService.axiosRef.put(`/api/setup-state`, setupState);
if (response.status !== HttpStatus.NO_CONTENT) {
throw new InternalServerErrorException(`Unexpected Status Code From Gateway: ${response.status}`, {
cause: response.statusText
});
}
}
}
12 changes: 12 additions & 0 deletions apps/api/src/gateway/gateway.synchronizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ export class GatewaySynchronizer implements OnApplicationBootstrap {
}

this.loggingService.log('Synchronizing with gateway...');

// A failure here must not stop assignments from synchronizing: the two are independent, and
// the next pass sends the state again.
try {
await this.gatewayService.updateRemoteSetupState({ activeLanguages: setupState.activeLanguages });
} catch (err) {
this.loggingService.error({
cause: err,
error: 'Failed to Update Remote Setup State'
});
}

let remoteAssignments: RemoteAssignment[];
try {
remoteAssignments = await this.gatewayService.fetchRemoteAssignments();
Expand Down
26 changes: 18 additions & 8 deletions apps/gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ rendered. `apps/api` is its only programmatic client — `apps/api/src/gateway/g
creates, fetches and deletes assignments here over HTTP, and the patient is given a link to
`/assignments/:id`.

**Traffic only ever flows API → gateway.** This app holds no address or credential for the API, so
it cannot ask for anything: whatever it needs to know about the instance it serves has to be pushed.
`PUT /api/setup-state` is that push, and `GatewaySynchronizer` in `apps/api` sends it on every pass
(every `GATEWAY_REFRESH_INTERVAL`, unconditionally) rather than only when the state changes. That is
what makes this app's copy safe to keep in memory: whatever it missed — the change itself, or its
whole copy across a restart — it is told again within one interval, so the window on
`DEFAULT_ACTIVE_LANGUAGES` is bounded without either side tracking what the other holds.

Read the root `AGENTS.md` first for the rules that apply everywhere.

**There is no client-side router, no TanStack Query and no Zustand.** Express routing
Expand Down Expand Up @@ -61,12 +69,14 @@ Its own SQLite database, entirely separate from the API's MongoDB: `prisma/schem
(`RemoteAssignmentModel`), generated to `node_modules/@prisma/generated-client` and wrapped in
`src/lib/prisma.ts` with a computed `getPublicKey()`.

A `Json` column is typed by `prisma-json-types-generator`, the same generator `apps/api` uses: a
`/// [TypeName]` docstring above the field names a type from the `PrismaJson` namespace declared in
`src/typings/prisma-json-types-generator.d.ts`, and the generated client uses it on both the read
and the write side. **The name must exist in that namespace** — the generator emits the reference
either way, so a typo surfaces as an unresolved type in the generated client rather than an error
from `prisma generate`.
**SQLite has no array or JSON column type**, so a non-scalar is serialized into a `String` and parsed
back on read — `targetStringified`, validated with `$InstrumentBundleContainer` in
`src/routers/root.router.ts`.

Everything else this app knows is in memory, and deliberately: the assignment verification set
(`src/lib/assignment-verification.ts`) and the instance's setup state (`src/lib/setup-state.ts`).
Neither is a record of something that happened here — one is a short-lived gate, the other a copy of
state `apps/api` owns — so persisting either would only add a second copy that can disagree.

`GATEWAY_DATABASE_URL` is an absolute `file:` URL written by `pnpm generate:env`. Turbo runs
`db:push` before `dev`, `lint`, `test:e2e` and — via the `@opendatacapture/gateway#build` key in
Expand All @@ -90,8 +100,8 @@ hydrated tree can disagree with the SSR'd HTML until you do.
## Conventions specific to here

- `@/` aliases `src/`, declared in both `vite.config.ts` and `tsconfig.json`.
- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: the `PrismaJson` namespace,
`res.locals.loadRoot`, `window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`.
- Ambient declarations live in `src/typings/` and `src/vite-env.d.ts`: `res.locals.loadRoot`,
`window.__ROOT_PROPS__`, the `cap-widget` JSX element, `__RELEASE__`.
- The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`,
`jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still
required, and there are no translation resource files — `src/services/i18n.ts` initializes with
Expand Down
1 change: 0 additions & 1 deletion apps/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
"esbuild": "catalog:",
"nodemon": "catalog:",
"prisma": "catalog:",
"prisma-json-types-generator": "^3.2.2",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"type-fest": "workspace:type-fest__4.x@*",
Expand Down
7 changes: 0 additions & 7 deletions apps/gateway/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,13 @@ generator client {
binaryTargets = ["native", "debian-openssl-1.1.x"]
}

generator json {
provider = "prisma-json-types-generator"
}

datasource db {
provider = "sqlite"
url = env("GATEWAY_DATABASE_URL")
}

model RemoteAssignmentModel {
id String @id
/// [ActiveLanguages]
// The default covers assignments created before an instance could choose which languages to offer.
activeLanguages Json @default("[\"en\",\"fr\"]")
createdAt DateTime @default(now())
completedAt DateTime?
expiresAt DateTime
Expand Down
21 changes: 21 additions & 0 deletions apps/gateway/src/lib/setup-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core';
import type { ActiveLanguages } from '@opendatacapture/schemas/core';
import type { RemoteSetupState } from '@opendatacapture/schemas/gateway';

/**
* The setup state of the instance this gateway serves, as last pushed by `apps/api`.
*
* Held in memory, consistent with the verification set in `assignment-verification.ts`. It is a
* copy of state the API owns rather than a record of anything that happens here, and the API
* re-sends it every `GATEWAY_REFRESH_INTERVAL`, so a restart costs at most one interval on the
* defaults below.
*/
let setupState: null | RemoteSetupState = null;

export function getActiveLanguages(): ActiveLanguages {
return setupState?.activeLanguages ?? DEFAULT_ACTIVE_LANGUAGES;
}

export function updateSetupState(state: RemoteSetupState): void {
setupState = state;
}
20 changes: 18 additions & 2 deletions apps/gateway/src/routers/api.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type {
MutateAssignmentResponseBody,
RemoteAssignment
} from '@opendatacapture/schemas/assignment';
import { $RemoteSetupState } from '@opendatacapture/schemas/gateway';
import type { GatewayHealthcheckSuccessResult } from '@opendatacapture/schemas/gateway';
import { Router } from 'express';

import { clearAssignmentVerification, isAssignmentVerified } from '@/lib/assignment-verification';
import { prisma } from '@/lib/prisma';
import { updateSetupState } from '@/lib/setup-state';
import { logger } from '@/logger';
import { ah } from '@/utils/async-handler';
import { HttpException } from '@/utils/http-exception';
Expand Down Expand Up @@ -47,12 +49,11 @@ router.post(
logger.error(result.error.issues);
throw new HttpException(400, 'Bad Request');
}
const { activeLanguages, instrumentContainer, publicKey, ...assignment } = result.data;
const { instrumentContainer, publicKey, ...assignment } = result.data;

await prisma.remoteAssignmentModel.create({
data: {
...assignment,
activeLanguages,
rawPublicKey: Buffer.from(publicKey),
targetStringified: JSON.stringify(instrumentContainer)
}
Expand Down Expand Up @@ -139,6 +140,21 @@ router.delete(
})
);

// A full replacement rather than a patch, so a retry, a duplicate and the periodic reconcile in
// `apps/api` are all the same request.
router.put(
'/setup-state',
ah(async (req, res) => {
const result = await $RemoteSetupState.safeParseAsync(req.body);
if (!result.success) {
logger.error(result.error.issues);
throw new HttpException(400, 'Bad Request');
}
updateSetupState(result.data);
res.status(204).end();
})
);

router.get('/healthcheck', (_, res) => {
res.status(200).json({
ok: true,
Expand Down
3 changes: 2 additions & 1 deletion apps/gateway/src/routers/root.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { $InstrumentBundleContainer } from '@opendatacapture/schemas/instrument'
import { Router } from 'express';

import { prisma } from '@/lib/prisma';
import { getActiveLanguages } from '@/lib/setup-state';
import { logger } from '@/logger';
import type { RootProps } from '@/Root';
import { ah } from '@/utils/async-handler';
Expand Down Expand Up @@ -43,7 +44,7 @@ router.get(

// Read server-side rather than from `window.location` so the SSR pass and the hydration pass
// resolve the same language; anything else renders the page in English and then swaps it.
const { activeLanguages } = assignment;
const activeLanguages = getActiveLanguages();
const requestedLanguage = $Language.safeParse(req.query.lang);
const language =
requestedLanguage.success && activeLanguages.includes(requestedLanguage.data)
Expand Down
7 changes: 0 additions & 7 deletions apps/gateway/src/typings/prisma-json-types-generator.d.ts

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "opendatacapture",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"private": true,
"packageManager": "pnpm@10.34.3",
"license": "Apache-2.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/instrument-bundler/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/instrument-bundler",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"sideEffects": [
"**/cli.ts"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/instrument-guidelines/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/instrument-guidelines",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).",
"license": "Apache-2.0",
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-url/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/playground-url",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"sideEffects": [
"**/cli.ts"
],
Expand Down
11 changes: 2 additions & 9 deletions packages/schemas/src/assignment/assignment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { $Uint8ArrayLike } from '@douglasneuroinformatics/libjs';
import { z } from 'zod/v4';

import { $ActiveLanguages, $BaseModel, $Json } from '../core/core.js';
import { $BaseModel, $Json } from '../core/core.js';
import { $InstrumentBundleContainer } from '../instrument/instrument.base.js';
export const $AssignmentStatus = z.enum(['CANCELED', 'COMPLETE', 'EXPIRED', 'OUTSTANDING']);

Expand Down Expand Up @@ -39,16 +39,9 @@ export const $CreateAssignmentData = z.object({
subjectId: z.string()
});

/**
* The DTO transferred from the core API to the external gateway when creating an assignment.
*
* The gateway has its own database and cannot read the instance's setup state, so the languages it
* may offer this patient travel with the assignment. They are a snapshot taken at creation: an
* assignment already sent keeps the languages it was created with.
*/
/** The DTO transferred from the core API to the external gateway when creating an assignment. */
export type CreateRemoteAssignmentInputData = z.input<typeof $CreateRemoteAssignmentData>;
export const $CreateRemoteAssignmentData = $RemoteAssignment.omit({ encryptedData: true, symmetricKey: true }).extend({
activeLanguages: $ActiveLanguages,
instrumentContainer: $InstrumentBundleContainer,
publicKey: $Uint8ArrayLike
});
Expand Down
13 changes: 13 additions & 0 deletions packages/schemas/src/gateway/gateway.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { z } from 'zod/v4';

import { $ActiveLanguages } from '../core/core.js';
import { $ReleaseInfo } from '../setup/setup.js';

/**
* The instance-level state the gateway needs in order to render an assignment, pushed by `apps/api`
* on every synchronization pass. Deliberately a projection of `SetupState` rather than the whole
* shape: the rest of it either describes the API's own process (`release`, `uptime`) or gates UI the
* gateway does not have, and the gateway is reachable from outside the instance's network, so it
* should hold only what it renders.
*/
export type RemoteSetupState = z.infer<typeof $RemoteSetupState>;
export const $RemoteSetupState = z.object({
activeLanguages: $ActiveLanguages
});

export type GatewayHealthcheckSuccessResult = z.infer<typeof $GatewayHealthcheckSuccessResult>;
export const $GatewayHealthcheckSuccessResult = z.object({
ok: z.literal(true),
Expand Down
2 changes: 1 addition & 1 deletion packages/serve-instrument/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/serve-instrument",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
Expand Down
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion runtime/v1/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/runtime-v1",
"type": "module",
"version": "2.2.1",
"version": "2.2.2",
"author": {
"name": "Douglas Neuroinformatics",
"email": "support@douglasneuroinformatics.ca"
Expand Down
Loading