Intake Gateway

Public patient-intake ingress. Host-resolves an organization, runs a multi-step draft session with encrypted answers, and on submit orchestrates a saga across patient-service and Canvas FHIR. The only service besides webhook-receiver that accepts unauthenticated public traffic.

Shipped in Wave A5 · 2026-04-21 · commit 3a8ee9c.

702 tests passing (698 unit + integration + 4 real-Postgres-skipped). Ships alongside the new @yourera/crypto shared package (21 tests) used for the Sealed-envelope encryption of answersSealed.

Overview

Intake-gateway is the public entrypoint patients hit when they load an intake site (e.g. intake.biologics.com). It owns the full lifecycle of an intake from first pageview to final submit, with the clinical answer payload encrypted at rest and the session bound to the browser via an HMAC-signed __Host- cookie.

What it owns

What it does NOT own

Public-traffic service. Only webhook-receiver and intake-gateway accept unauthenticated traffic. Every defense listed under Security posture exists because this service is internet-facing.

Database Schema

intake_sessions

One row per intake attempt. The clinical payload is encrypted in answersSealed; every other column is operational metadata.

FieldTypeNotes
iduuidPK. Also the session identifier inside the cookie payload.
organizationIduuidResolved from Host header at POST /sessions time. Immutable for the life of the session.
intakeTypetextweightloss for v1. Controls which disqualifier module + answer schema apply.
statusenumdraft / submitted / abandoned / disqualified. State transitions are one-way.
currentSlideIdtextCursor into the slide graph for resume rendering.
historyjsonbBack-stack of visited slide IDs so clients can implement the back button without recomputing.
answersSealedjsonbSealed envelope (see Encryption). Plaintext never persisted.
answersSchemaVersionintBumped when the per-intake-type answer schema shape changes. Drives decrypt-time migration.
contactEmailtextCaptured during bind-email. Lowercased. Indexed for duplicate lookup.
contactEmailVerifiedbooleanFlipped true on a successful OTP verify.
cookieTokenHashbyteasha256(random token). The random token itself lives only in the browser cookie. A DB dump can't forge a cookie.
expiresAttimestamptzDrafts past this timestamp are flipped to abandoned by the expiry-sweep worker.
submitResultjsonbPopulated on terminal success. Contains patientId, canvasPatientId, and created-resource IDs for audit.
partialSubmitResultjsonbStash for mid-saga progress. Enables the C-3 replay path (see Submit saga).
disqualifiedReasontextPopulated when the disqualifier module returns a hit. Machine-readable code, not a user-facing sentence.
createdAttimestamptz
updatedAttimestamptz

email_otp_codes

One row per OTP issuance. Codes are bcrypt-hashed; the plaintext 6-digit code is only ever sent in the outbound email.

FieldTypeNotes
iduuidPK.
sessionIduuidFK to intake_sessions.id.
emailtextLowercased. Independently rate-limited from session (see Rate limits).
codeHashtextbcrypt(plaintext code, cost=10).
attemptsintIncremented on every verify. Locks at a low threshold.
expiresAttimestamptzShort TTL.
consumedAttimestamptzNullable. Set on first successful verify; subsequent verifies no-op.
createdAttimestamptz

Also present: outbox and audit_events, the shared-pattern tables.

API

Session lifecycle

POST /sessions

Public. Create a draft session. Resolves the Host header to an organization via organization-service /public/resolve-domain (requires matchedPurpose='intake'), INSERTs the intake_sessions row, sets the __Host-hisera_session cookie, and emits intake.started. Per-IP-rate-limited (see Rate limits).

GET /sessions/me

Public, session-cookie-authed. Loads the current draft. Decrypts answersSealed server-side; ships the plaintext answers map back only if the cookie HMAC is valid and sha256(token) matches a live draft row.

PATCH /sessions/me

Public, session-cookie-authed. Merge-patch of the answers map. Server decrypts the current envelope, merges the delta, re-seals, and writes the new envelope atomically. Also accepts currentSlideId and history updates.

DELETE /sessions/me

Public, session-cookie-authed. Marks the session abandoned and emits intake.abandoned. Idempotent on already-terminal sessions.

Email binding + OTP

POST /sessions/me/bind-email

Public, session-cookie-authed. First-time email capture. Stores contactEmail on the session, issues an OTP via notification-service, and emits intake.email_bound. Rate-limited per email and per session independently.

POST /sessions/me/verify-email

Public, session-cookie-authed. Submit a 6-digit code. Compare is timing-safe via bcrypt, including when no code or session exists (we run bcrypt against a dummy hash to equalize response time). On success, flips contactEmailVerified=true and marks the OTP row consumedAt.

Resume (cross-device)

POST /sessions/resume

Public. A patient returns on a new device and asks us to email them a link to their existing draft. We look up an active draft matching (organizationId, contactEmail); if one exists we issue an OTP. Always returns 204 regardless of whether a matching draft exists, to prevent enumeration. The DB lookup always runs — against a sentinel UUID on the unknown-host path — so query timing is identical.

POST /sessions/resume/verify

Public. Verify the OTP from the resume email. On success, rotates the cookie (issues a new random token, updates cookieTokenHash) so the original device's cookie is invalidated.

Submit

POST /sessions/me/submit

Public, session-cookie-authed, verified-email required. Runs the full multi-step saga. See Submit saga for the step-by-step flow and the partial-failure recovery via partialSubmitResult.

Admin

GET /admin/sessions

Support + superadmin. Listing with filters. Metadata only — no decrypted answers, no contactEmail beyond redacted-for-support-UI form.

GET /admin/sessions/:id

Superadmin only. Decrypts answersSealed and returns the full clinical answer map. PHI — gated behind the highest role tier and written to audit_events.

POST /admin/workers/expiry/tick

Superadmin only. Synchronous expiry-sweep tick for incident response.

POST /admin/workers/otp-cleanup/tick

Superadmin only. Synchronous OTP-cleanup tick.

POST /admin/workers/outbox/tick

Superadmin only. Synchronous outbox tick.

Health

GET /health

Liveness probe. Process-up only; does not touch the DB.

GET /health/ready

Readiness probe. DB ping and an organization-service reachability probe — we cannot accept POST /sessions without org resolution, so the service is unready if organization-service is down.

Cookie & Session Binding

Every authed session endpoint authenticates via a single cookie: __Host-hisera_session. The cookie carries an HMAC-signed random token; the server stores sha256(token) in intake_sessions.cookieTokenHash.

Why this shape

Verification flow on each request

  1. Read __Host-hisera_session. Missing → 401.
  2. Verify HMAC signature against server secret. Invalid → 401.
  3. Parse {sessionId, token}.
  4. Compute sha256(token) and compare with constant-time equality to cookieTokenHash from intake_sessions.id = sessionId.
  5. If session is not draft → 410 Gone. Terminal sessions can't be mutated.
  6. Attach sessionId to the request context. Proceed.

Encryption: @yourera/crypto

The answersSealed column holds a Sealed envelope produced by the new @yourera/crypto shared package. This is a package, not a service — it ships with the intake-gateway monorepo commit and will be consumed by other services in subsequent waves.

v1: AES-256-GCM

Today the envelope uses AES-256-GCM with a key loaded from an environment variable at service start. The envelope shape is:

{
  "v": 1,                          // envelope version
  "alg": "AES-256-GCM",
  "kid": "<key id>",               // which key signed this
  "iv": "<base64 iv>",
  "tag": "<base64 auth tag>",
  "ct": "<base64 ciphertext>"
}

v2 upgrade path: KMS

The version field (v) exists so we can add a KMS-backed envelope (v=2) later without touching stored rows. Decrypt dispatches on v; v1 and v2 will coexist for the duration of the migration and v1 rows get re-sealed on next write.

Key rotation. kid lets us run multiple keys simultaneously during rotation. Each key is a full random 256-bit secret; no derivation.

What gets sealed

Only the clinical answers map. Operational fields (status, expiresAt, slide IDs, history) are stored plaintext because disqualifier queries, resume lookups, and the expiry sweep all need to run over them without a decrypt.

Submit Saga

POST /sessions/me/submit runs a saga across intake-gateway's own DB, patient-service, and Canvas FHIR. Partial failure is handled by stashing progress in intake_sessions.partialSubmitResult and replaying from the last successful step on retry.

Steps

  1. Decrypt answers. Unseal answersSealed into the in-memory answers map. If the envelope v is unknown → abort with 500; op-alert.
  2. Disqualifier evaluation. Call the registered disqualifier module for this intakeType (weightloss for v1) with the unsealed answers. Server-authoritative — the client's own view is advisory. On hit: mark status=disqualified, write disqualifiedReason, emit intake.disqualified, return to client.
  3. Duplicate check. Look up (organizationId, contactEmail) in patient-service /patients/email-lookup. If a live patient exists with the same identity, return an existing-patient payload; client branches to sign-in / contact-support instead of finalizing. Does NOT consume the session — the row stays a draft so the patient can resume if they dismiss the existing-patient prompt.
  4. Create patient-directory row (C-1). POST patient-service /patients with the identifying fields plus organizationId. Response carries patientId. Stash it in partialSubmitResult before proceeding. On failure before stash: safe to retry — duplicate check will short-circuit.
  5. Canvas clinical writes (C-2). Via @yourera/canvas-client:
    • QuestionnaireResponse — the canonical record of what the patient answered.
    • Observation rows for quantifiable answers (height, weight, BMI, etc.).
    • AllergyIntolerance per reported allergy.
    • Condition per reported diagnosis.
    Each resource's Canvas ID is appended to partialSubmitResult as it succeeds. On any Canvas error mid-batch: write the current partialSubmitResult back to the DB and return 500. The session stays draft; next retry picks up from the last stashed step.
  6. Transactional finalize (C-3). In a single DB transaction: flip status=submitted, copy partialSubmitResult into submitResult, clear partialSubmitResult, insert the intake.submitted row into outbox. The outbox-worker picks it up and publishes to EventBridge.
C-3 recovery is the subtle step. If the Canvas batch completes but the finalize transaction fails (e.g., DB blip), the session is still draft with a fully-populated partialSubmitResult. The next retry of /submit detects this, skips C-1 and C-2 entirely, and runs only C-3. The disqualifier re-eval on retry is idempotent because the answers haven't changed.

Consumers of intake.submitted

Disqualifiers

Disqualifier evaluation is server-authoritative. Per-intake-type modules are pure functions over the unsealed answers map; the client's UI-level disqualifier checks are advisory only. A client that skips its own check still gets disqualified at submit time.

Weightloss (v1)

The registered weightloss module returns a disqualifier hit on any of:

Reason codeTrigger
age_under_18Calendar-correct age < 18 at submit time.
age_over_75Calendar-correct age > 75.
bmi_under_27Computed BMI below the clinical floor.
pregnant_or_breastfeedingSelf-reported.
type_1_diabetesSelf-reported diagnosis.
active_cancerSelf-reported.
men2_or_mtc_historyPersonal or family history flag.
eating_disorder_activeSelf-reported active ED.
severe_renal_impairmentSelf-reported.
glp1_contraindication_allergyKnown allergy to a GLP-1 agonist.
pancreatitis_historyHistory of pancreatitis.
Calendar-correct age. Age is computed against the submit timestamp with a proper month/day comparison — not floor((now - dob)/365.25). This matters for patients who are 17 on the day they fill out the intake but would be 18 at submit, or vice versa.

Module contract

A disqualifier module is a pure function: (answers: UnsealedAnswers, ctx: { submitAt: Date }) => DisqualifierResult. No I/O, no randomness. All state required for the evaluation is in the unsealed answers map. New intake types register a module in the same table and the saga picks it up by intakeType.

Events Emitted

All events are defined in @yourera/contracts. The outbox-worker is the sole publisher; direct EventBridge writes from request handlers are forbidden.

EventPayload shapeConsumers
intake.started { sessionId, organizationId, intakeType, startedAt } notification-service (lead capture pre-email), analytics pipeline
intake.email_bound { sessionId, organizationId, contactEmail, boundAt } notification-service (lead identification)
intake.submitted { sessionId, organizationId, patientId, canvasPatientId, intakeType, submittedAt } payment-service (SetupIntent), notification-service (welcome), pharmacy-router (routing envelope)
intake.abandoned { sessionId, organizationId, intakeType, abandonedAt, reason } notification-service (re-engagement campaigns), analytics
intake.disqualified { sessionId, organizationId, intakeType, reason, disqualifiedAt } notification-service (soft-landing messaging), analytics

intake.abandoned is emitted by the expiry-sweep worker, not a request handler. Its reason is always expired in v1; user-initiated abandonment (DELETE /sessions/me) emits with reason=user_abandoned.

Rate Limits

Per-IP request limits

Applied by a lightweight middleware keyed on req.ip (forensically-correct via trust proxy 1).

Route groupLimitWhy
POST /sessions, POST /sessions/resume, POST /sessions/resume/verify10 / min / IPCreates DB rows, sends emails. Tight.
/sessions/me/* (GET, PATCH, bind-email, verify-email, submit)60 / min / IPHot path while a patient is filling out the form.

OTP rate limits

WindowScopeLimit
15 minper email3 OTP issuances
60 sper session1 OTP issuance
per codeper OTP rowbounded attempts before lockout

The per-email window protects against a hostile sender pumping mail through a victim's address; the per-session window prevents a single tab from triggering a flood.

Security Posture

This service is public-internet-facing and holds PHI-shaped data. The defenses below are all in production as of this wave.

Cookie integrity

CSRF

Timing-safe OTP compare

The verify handler always runs a bcrypt compare, even if the session or the OTP row doesn't exist. The "no code to compare against" path runs bcrypt against a dummy hash. This equalizes response time regardless of whether the session ID or email is valid, preventing an attacker from enumerating valid sessions by measuring server response latency.

Enumeration-leak prevention

POST /sessions/resume returns 204 unconditionally. On an unknown host or unknown email, the handler still runs a DB lookup against a sentinel UUID so total query time is identical to the known-match path. The response body is empty in both cases. A hostile reconnaissance run cannot distinguish "org exists + no draft" from "org doesn't exist".

Dev-only fallbacks

Several config values have dev-only defaults (e.g. a throwaway cookie-HMAC secret). These are gated behind env === 'dev'. Production startup throws and refuses to serve traffic if any required env var is missing — no hardcoded-in-repo secret can leak through a forgotten env var.

Transport + standard headers

Host→Org resolution

POST /sessions only accepts a resolved org if organization-service returns matchedPurpose='intake'. A hostname configured for the patient portal won't accidentally serve an intake; misconfiguration fails closed.

Workers

Expiry-sweep

Polls intake_sessions for rows with status='draft' and expiresAt < now(), flips them to abandoned, and inserts an intake.abandoned row (reason='expired') into outbox. Runs on a short interval; manual tick available at POST /admin/workers/expiry/tick.

OTP-cleanup

Deletes rows from email_otp_codes that are past expiresAt and consumed-or-exhausted. Pure housekeeping. Manual tick at POST /admin/workers/otp-cleanup/tick.

Outbox-worker

Standard shared-pattern outbox worker. Consumes outbox rows and publishes to EventBridge with a claim-and-ack pattern.

Admin Endpoints & PHI Tiering

Admin routes split along PHI boundaries:

EndpointRoleContains PHI?
GET /admin/sessionssupport + superadminNo — metadata only
GET /admin/sessions/:idsuperadmin onlyYes — decrypts answersSealed
POST /admin/workers/*/ticksuperadmin onlyNo

Every call to the PHI-returning endpoint writes an audit_events row with the caller's admin ID, the target sessionId, and the timestamp.

TODOs & Future Waves