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.
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
- Intake session lifecycle. Status enum:
draft/submitted/abandoned/disqualified. Sessions are persisted from the first POST and survive browser closes and device switches (see resume flow). - Encrypted answers.
answersSealedis a JSONB Sealed envelope produced by@yourera/crypto. The plaintext answers map is never written to disk. - Pre-Clerk email OTP. 6-digit codes with bcrypt hashes, rate-limited per email and per session. Placeholder until the Clerk swap-out lands in a later wave.
- Host → Org resolution. Calls
organization-service/public/resolve-domainand only accepts responses wherematchedPurpose='intake'. - Submit saga. Disqualifier evaluation → duplicate check → patient-service
/patients→ Canvas FHIR clinical writes (QuestionnaireResponse,Observation,AllergyIntolerance,Condition) → transactional finalize. Partial failure mid-saga is recoverable via apartialSubmitResultstash on the session row. - Server-authoritative disqualifier evaluation. Per-intake-type modules are pure functions over the unsealed answers map. Registered for v1:
weightloss. - Background workers. Expiry-sweep (flips old drafts to
abandoned), OTP-cleanup, and the standard outbox-worker.
What it does NOT own
- Payment collection. Payment-service listens for
intake.submittedand issues a Stripe SetupIntent out of band. No card data touches intake-gateway. - Patient self-report tracking. No shot logs, daily journals, or side-effect trackers. Intake is capture-and-finalize only.
- Clerk sessions. The email-OTP flow here is a temporary stand-in until the patient-portal Clerk integration can be reused.
- Canvas resource modeling. The FHIR shapes written at submit are delegated to
@yourera/canvas-client. Intake-gateway orchestrates; canvas-client writes.
Database Schema
intake_sessions
One row per intake attempt. The clinical payload is encrypted in answersSealed; every other column is operational metadata.
| Field | Type | Notes |
|---|---|---|
id | uuid | PK. Also the session identifier inside the cookie payload. |
organizationId | uuid | Resolved from Host header at POST /sessions time. Immutable for the life of the session. |
intakeType | text | weightloss for v1. Controls which disqualifier module + answer schema apply. |
status | enum | draft / submitted / abandoned / disqualified. State transitions are one-way. |
currentSlideId | text | Cursor into the slide graph for resume rendering. |
history | jsonb | Back-stack of visited slide IDs so clients can implement the back button without recomputing. |
answersSealed | jsonb | Sealed envelope (see Encryption). Plaintext never persisted. |
answersSchemaVersion | int | Bumped when the per-intake-type answer schema shape changes. Drives decrypt-time migration. |
contactEmail | text | Captured during bind-email. Lowercased. Indexed for duplicate lookup. |
contactEmailVerified | boolean | Flipped true on a successful OTP verify. |
cookieTokenHash | bytea | sha256(random token). The random token itself lives only in the browser cookie. A DB dump can't forge a cookie. |
expiresAt | timestamptz | Drafts past this timestamp are flipped to abandoned by the expiry-sweep worker. |
submitResult | jsonb | Populated on terminal success. Contains patientId, canvasPatientId, and created-resource IDs for audit. |
partialSubmitResult | jsonb | Stash for mid-saga progress. Enables the C-3 replay path (see Submit saga). |
disqualifiedReason | text | Populated when the disqualifier module returns a hit. Machine-readable code, not a user-facing sentence. |
createdAt | timestamptz | |
updatedAt | timestamptz |
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.
| Field | Type | Notes |
|---|---|---|
id | uuid | PK. |
sessionId | uuid | FK to intake_sessions.id. |
email | text | Lowercased. Independently rate-limited from session (see Rate limits). |
codeHash | text | bcrypt(plaintext code, cost=10). |
attempts | int | Incremented on every verify. Locks at a low threshold. |
expiresAt | timestamptz | Short TTL. |
consumedAt | timestamptz | Nullable. Set on first successful verify; subsequent verifies no-op. |
createdAt | timestamptz |
Also present: outbox and audit_events, the shared-pattern tables.
API
Session lifecycle
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).
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.
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.
Public, session-cookie-authed. Marks the session abandoned
and emits intake.abandoned. Idempotent on already-terminal sessions.
Email binding + OTP
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.
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)
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.
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
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
Support + superadmin. Listing with filters. Metadata only — no decrypted answers, no contactEmail beyond redacted-for-support-UI form.
Superadmin only. Decrypts answersSealed and returns the full clinical
answer map. PHI — gated behind the highest role tier and written to audit_events.
Superadmin only. Synchronous expiry-sweep tick for incident response.
Superadmin only. Synchronous OTP-cleanup tick.
Superadmin only. Synchronous outbox tick.
Health
Liveness probe. Process-up only; does not touch the DB.
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
__Host-prefix. ForcesSecure,Path=/, noDomain. The cookie cannot leak up to a parent domain or be set over HTTP.- HMAC signature. The body is
{sessionId, token, issuedAt}signed with a server secret. Clients cannot forge a new cookie by guessing a sessionId + token combination. sha256(token)at rest. A DB dump leaks the hash but not the token. Forging a cookie from a DB dump requires inverting sha256.- Rotation on resume-verify. Cross-device resume flips the stored hash to a new token so the original device's cookie stops working.
Verification flow on each request
- Read
__Host-hisera_session. Missing → 401. - Verify HMAC signature against server secret. Invalid → 401.
- Parse
{sessionId, token}. - Compute
sha256(token)and compare with constant-time equality tocookieTokenHashfromintake_sessions.id = sessionId. - If session is not
draft→ 410 Gone. Terminal sessions can't be mutated. - Attach
sessionIdto 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.
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
-
Decrypt answers. Unseal
answersSealedinto the in-memory answers map. If the envelopevis unknown → abort with 500; op-alert. -
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: markstatus=disqualified, writedisqualifiedReason, emitintake.disqualified, return to client. -
Duplicate check. Look up
(organizationId, contactEmail)inpatient-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 adraftso the patient can resume if they dismiss the existing-patient prompt. -
Create patient-directory row (C-1).
POST patient-service /patientswith the identifying fields plusorganizationId. Response carriespatientId. Stash it inpartialSubmitResultbefore proceeding. On failure before stash: safe to retry — duplicate check will short-circuit. -
Canvas clinical writes (C-2). Via
@yourera/canvas-client:QuestionnaireResponse— the canonical record of what the patient answered.Observationrows for quantifiable answers (height, weight, BMI, etc.).AllergyIntoleranceper reported allergy.Conditionper reported diagnosis.
partialSubmitResultas it succeeds. On any Canvas error mid-batch: write the currentpartialSubmitResultback to the DB and return 500. The session staysdraft; next retry picks up from the last stashed step. -
Transactional finalize (C-3). In a single DB transaction: flip
status=submitted, copypartialSubmitResultintosubmitResult, clearpartialSubmitResult, insert theintake.submittedrow intooutbox. The outbox-worker picks it up and publishes to EventBridge.
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
- payment-service — creates a Stripe Customer + SetupIntent.
- notification-service — sends the welcome email using the org's verified sender.
- pharmacy-router — reads the submit envelope for the eventual routing decision.
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 code | Trigger |
|---|---|
age_under_18 | Calendar-correct age < 18 at submit time. |
age_over_75 | Calendar-correct age > 75. |
bmi_under_27 | Computed BMI below the clinical floor. |
pregnant_or_breastfeeding | Self-reported. |
type_1_diabetes | Self-reported diagnosis. |
active_cancer | Self-reported. |
men2_or_mtc_history | Personal or family history flag. |
eating_disorder_active | Self-reported active ED. |
severe_renal_impairment | Self-reported. |
glp1_contraindication_allergy | Known allergy to a GLP-1 agonist. |
pancreatitis_history | History of pancreatitis. |
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.
| Event | Payload shape | Consumers |
|---|---|---|
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 group | Limit | Why |
|---|---|---|
POST /sessions, POST /sessions/resume, POST /sessions/resume/verify | 10 / min / IP | Creates DB rows, sends emails. Tight. |
/sessions/me/* (GET, PATCH, bind-email, verify-email, submit) | 60 / min / IP | Hot path while a patient is filling out the form. |
OTP rate limits
| Window | Scope | Limit |
|---|---|---|
| 15 min | per email | 3 OTP issuances |
| 60 s | per session | 1 OTP issuance |
| per code | per OTP row | bounded 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
- HMAC-signed cookie body, server-side secret loaded from env at boot.
__Host-prefix forcesSecure,Path=/, noDomain.sha256(token)at rest — a DB dump can't forge cookies without inverting sha256.- Cookie rotation on cross-device resume-verify invalidates the previous device.
CSRF
SameSite=Laxon the session cookie.Origin/Refererallowlist check against the resolved org's intake hostname set.- All state-changing endpoints require a custom header
X-Requested-With: XMLHttpRequest. Form-POST attacks can't set custom headers; browsers preflight any request with them.
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
Cache-Control: no-storeon every session route. No intermediate cache retains session payloads.- helmet baseline (CSP, frameguard, noSniff, HSTS in prod, etc.).
trust proxy 1soreq.ipis the ALB's forwarded client IP, not the ALB's own IP. Rate limits and audit logs are keyed on the real client.
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:
| Endpoint | Role | Contains PHI? |
|---|---|---|
GET /admin/sessions | support + superadmin | No — metadata only |
GET /admin/sessions/:id | superadmin only | Yes — decrypts answersSealed |
POST /admin/workers/*/tick | superadmin only | No |
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
- Clerk swap-out. The email-OTP flow here is a placeholder. Once patient-portal's Clerk integration is in place, intake-gateway delegates email verification to Clerk and drops the
email_otp_codestable. - payment-service integration.
intake.submittedconsumer is landed in intake-gateway but payment-service itself is still in Planned state. The envelope shape is pinned to avoid a breaking change. - KMS upgrade to crypto v2. The Sealed envelope has
vreserved for a future KMS-backed alg. v1 rows re-seal on next PATCH. - Playwright E2E. Unit + integration coverage is thorough but we don't yet drive a full browser through the resume-across-devices flow. Tracked for the test-infra wave.
- Additional intake types. The disqualifier-module contract supports new intake types drop-in. v2 will add NAD+ and microdose modules.
- Rate-limit upgrade. The in-memory per-IP limiter works for a single-instance deploy. A Redis-backed shared limiter is pending once we scale horizontally.