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
33 changes: 32 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Each module under `backend/src/modules/<name>/` owns its own `routes/`, `control
- `lab-orders` — lab order issuance and versioning.
- `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7).
- `notifications` — outbound notification dispatch, provider-agnostic.
- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here.

## 4. Data models

Expand Down Expand Up @@ -197,6 +198,36 @@ LoginAttempt (issue #35)
`User` fields added in Phase 8 live in §4.1; the `email_verified_at` /
`EmailVerificationToken` pair (issue #34) is in §4.1 as well.

### 4.10 Audit trail (Phase 8, issue #43)
```
AuditEvent
id, actor_user_id (nullable — null = system), actor_role (nullable Role)
entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' so far), entity_id
action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable)
request_id (X-Request-Id of the causing request), metadata (jsonb, nullable)
created_at
# APPEND-ONLY: no update/delete path in code, and BEFORE UPDATE/DELETE
(row) + BEFORE TRUNCATE (statement) triggers reject mutation in the
database. A future retention policy must drop/recreate them in its own
reviewed migration.
# No foreign keys on purpose: an ON DELETE action would have to rewrite
audit rows (forbidden), and RESTRICT would let the trail block account
lifecycle (e.g. issue #41's purge). Ids are recorded as facts.
# Written only by audit.record() inside the transaction of the change it
describes — the event exists iff the change committed.
# metadata: <= 16 flat scalar keys, strings <= 200 chars; keys that look
like personal/secret data (name, email, phone, dob, notes, intake,
password, secret, token, key, body, payload, …) are refused. Never PHI.
# created_at is stamped by Prisma on the app server per row (ms
precision); same-millisecond rows are ordered by id.
# indexes: (entity_type, entity_id, created_at), (actor_user_id,
created_at), (created_at, id) — the last backs the admin cursor.
```
Transitions recorded (action: from → to):
- `appointment.booked`: `null → scheduled` (metadata: slot id, doctor profile id, origin)
- `doctor.verification_status_changed`: previous → new verification status (read under `FOR UPDATE`; a no-op set records nothing)
- `payment_intent.funded` `created → escrowed`, `payment_intent.released` `escrowed → released`, `payment_intent.refunded` `escrowed → refunded` (metadata: order type, amount, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing.

## 5. Key flows (sequence-level, not code)

### 5.1 Booking (concurrency-safe)
Expand Down Expand Up @@ -230,7 +261,7 @@ LoginAttempt (issue #35)
1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.)
2. Referral `expired` status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem).
3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) — `AvailabilitySlot` currently assumes one calendar per doctor.
4. No audit-log table — every FR around auditability (PRD §7) currently relies on `created_at/updated_at` timestamps only, which is not sufficient for "who changed what." A dedicated `AuditEvent` table is likely needed before this ships to real patients.
4. No audit-log table — every FR around auditability (PRD §7) currently relies on `created_at/updated_at` timestamps only, which is not sufficient for "who changed what." A dedicated `AuditEvent` table is likely needed before this ships to real patients. **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking, doctor verification and payment fund/release/refund. **Still open:** Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must be wired as they're built; the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22).
5. CSRF on the cookie-authenticated routes — the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site — see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern — the usual answer — is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login.

### 6.3 What's overengineered (architecture-level, for v1)?
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
- No routing rule yet for a referral with no specific target doctor (which specialist gets it?).
- No expiry/TTL job for held slots or pending referrals — currently just an enum value with no worker.
- Polymorphic `FulfillmentRecord.order_id` is a sketch, not final — may need splitting into two tables.
- No `AuditEvent` table yet, despite PRD requiring auditability — needed before real patient data.
- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking, doctor verification and payment fund/release/refund only — **Phase 3/4 transitions must call it when they're built.** Never put PHI in `metadata`.
- No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path.
- One doctor = one calendar assumed; multi-facility doctors not modeled.

Expand Down
14 changes: 13 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ROADMAP.md — DocForum

Last updated: 2026-09-26
Last updated: 2026-10-17
Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see
`docs/adr/0004-custodial-payments-v1.md`) built and tested. Phases 2-6
and 8-9 scoped into GitHub issues (#1-#22, #21 closed); Phase 7 is
Expand Down Expand Up @@ -113,6 +113,7 @@ for status and issue links.
- [x] Login brute-force protection: per-account and per-IP throttling with backoff. Postgres-backed buckets (`LoginAttempt`), a single conditional upsert so concurrent failures are counted atomically, exponential backoff with `Retry-After`, a bounded temporary lock, an admin clear endpoint, a prune interval in `src/server.ts`, and a `TRUST_PROXY` setting so the IP isn't Render's proxy address. Tracked as [issue #35](https://github.com/DocForum/docforum-core/issues/35) (High, 200 pts) — **Done**, merged as PR #138.
- [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie — a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) — **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`.
- [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) — **in progress**.
- [ ] `AuditEvent` table and write path — **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking, doctor verification and payment fund/release/refund. Referrals, prescriptions, lab orders and fulfillment (Phases 3–5) must call `record()` when they land; the login-throttle admin clear still logs to console only. Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`.
- [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module.
- [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`.
- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue.
Expand Down Expand Up @@ -380,3 +381,14 @@ for status and issue links.
tests assert a
forged cross-origin request is rejected while the legitimate cross-site
client still succeeds.
- 2026-10-17 — Issue #43 (AuditEvent foundation) opened as a PR: the
`AuditEvent` table (append-only, enforced by DB triggers; no FKs on
purpose), an `audit` module whose `record()` refuses to run outside a
transaction, an `X-Request-Id` middleware, and `GET /admin/audit-events`
with a keyset cursor. Wired into booking (inside `bookSlot`'s existing
transaction, after the slot claim — the concurrency guarantee is
unchanged), doctor verification (previous status read `FOR UPDATE`) and
payment fund/release/refund (status writes now conditional on the
observed state, so a racing second action gets 409 instead of silently
overwriting). Rollback coupling proven with a real injected DB failure,
not a mock. Phase 3/4 wiring explicitly left pending.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- CreateTable
CREATE TABLE "AuditEvent" (
"id" TEXT NOT NULL,
"actorUserId" TEXT,
"actorRole" "Role",
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"fromState" TEXT,
"toState" TEXT,
"requestId" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "AuditEvent_entityType_entityId_createdAt_idx" ON "AuditEvent"("entityType", "entityId", "createdAt");

-- CreateIndex
CREATE INDEX "AuditEvent_actorUserId_createdAt_idx" ON "AuditEvent"("actorUserId", "createdAt");

-- CreateIndex
CREATE INDEX "AuditEvent_createdAt_id_idx" ON "AuditEvent"("createdAt", "id");


-- Append-only enforcement (Phase 8, issue #43). The application has no
-- update/delete path for AuditEvent; these triggers make the database refuse
-- one too, for every role that doesn't disable triggers. Row-level for
-- UPDATE/DELETE, statement-level for TRUNCATE (which bypasses row triggers).
--
-- A future, reviewed retention policy that must remove old events has to do
-- so in its own migration that explicitly drops/recreates these triggers —
-- deliberately loud, never an incidental side effect of application code.
CREATE FUNCTION "audit_event_reject_mutation"() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'AuditEvent is append-only: % is not allowed', TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;

CREATE TRIGGER "AuditEvent_no_update_delete"
BEFORE UPDATE OR DELETE ON "AuditEvent"
FOR EACH ROW EXECUTE FUNCTION "audit_event_reject_mutation"();

CREATE TRIGGER "AuditEvent_no_truncate"
BEFORE TRUNCATE ON "AuditEvent"
FOR EACH STATEMENT EXECUTE FUNCTION "audit_event_reject_mutation"();
38 changes: 38 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,41 @@ model WalletLink {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

// --- Audit trail (Phase 8, issue #43) -------------------------------------
// One row per state transition on a record that matters for clinical,
// legal or financial accountability (PRD §7: "who, when"). Written by the
// audit module's record(), always inside the same transaction as the
// change it describes — if the change rolls back, so does its event.
//
// APPEND-ONLY. There is no update or delete path in code, and a database
// trigger (see the add_audit_event migration) rejects UPDATE, DELETE and
// TRUNCATE outright. Deliberately no foreign keys: an FK's ON DELETE action
// would have to rewrite or remove audit rows (which the trigger forbids),
// and RESTRICT would let the audit trail block account lifecycle. Ids are
// recorded as facts about the past.
//
// No PHI, ever: `metadata` is a small flat map of ids/codes validated by
// record() — never names, contact details, DOB, intake text, or copies of
// request/response bodies.
model AuditEvent {
id String @id @default(uuid())
// Null only for system-initiated transitions (none exist yet).
actorUserId String?
actorRole Role?
// e.g. 'Appointment', 'DoctorProfile', 'PaymentIntent'.
entityType String
entityId String
// e.g. 'appointment.booked', 'payment_intent.released'.
action String
fromState String?
toState String?
// X-Request-Id of the HTTP request that caused it (src/middleware/request-id.ts).
requestId String?
metadata Json?
createdAt DateTime @default(now())

@@index([entityType, entityId, createdAt])
@@index([actorUserId, createdAt])
@@index([createdAt, id])
}
6 changes: 6 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { doctorsRouter } from './modules/doctors/routes';
import { availabilityRouter } from './modules/availability/routes';
import { appointmentsRouter } from './modules/appointments/routes';
import { paymentsRouter } from './modules/payments/routes';
import { auditRouter } from './modules/audit/routes';
import { requestId } from './middleware/request-id';

export function createApp() {
const app = express();
Expand All @@ -20,6 +22,9 @@ export function createApp() {
// Explicit allowlist per environment (Phase 8, issue #37) — see
// config.corsOrigins. `credentials: true` is required for the refresh
// cookie and is exactly why the allowlist can't be a wildcard.
// Phase 8, issue #43: first, so every response (errors included) carries
// an X-Request-Id and audit events can be tied back to it.
app.use(requestId);
app.use(cors({ origin: config.corsOrigins, credentials: true }));
app.use(express.json());
app.use(cookieParser());
Expand All @@ -31,6 +36,7 @@ export function createApp() {
app.use('/availability', availabilityRouter);
app.use('/appointments', appointmentsRouter);
app.use('/payments', paymentsRouter);
app.use('/admin', auditRouter);

// Must be registered last — Express only routes errors here once every
// other handler has had a chance to run.
Expand Down
38 changes: 38 additions & 0 deletions backend/src/middleware/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Request ids (Phase 8, issue #43). Every request gets one, so an audit
// event can be tied back to the HTTP request (and its logs) that caused it.
// An inbound `X-Request-Id` (e.g. from Render's proxy or a client) is kept
// when it's a sane token; otherwise a fresh UUID is minted. Echoed back in
// the response header either way.
import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import type { AuditActor } from '../modules/audit/services';

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
requestId?: string;
}
}
}

export const REQUEST_ID_HEADER = 'x-request-id';

// Bounded charset and length: the value is stored and logged, so it must
// not be a vehicle for arbitrary client data.
const ACCEPTABLE_REQUEST_ID = /^[A-Za-z0-9._:-]{8,128}$/;

export function requestId(req: Request, res: Response, next: NextFunction) {
const inbound = req.get(REQUEST_ID_HEADER);
const id = inbound && ACCEPTABLE_REQUEST_ID.test(inbound) ? inbound : randomUUID();
req.requestId = id;
res.set('X-Request-Id', id);
next();
}

/** The audit actor for an authenticated request: who, in which role, under
* which request id. Controllers pass this into the service whose state
* change is being audited. */
export function auditActor(req: Request): AuditActor {
return { userId: req.user?.id ?? null, role: req.user?.role ?? null, requestId: req.requestId ?? null };
}
3 changes: 2 additions & 1 deletion backend/src/modules/appointments/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import type { Request, Response } from 'express';
import * as appointmentsService from '../services';
import { bookSlotSchema } from '../validation';
import { HttpError } from '../../../http-error';
import { auditActor } from '../../../middleware/request-id';

export async function book(req: Request, res: Response) {
if (!req.user) throw new HttpError(401, 'Authentication required.');
const { availabilitySlotId } = bookSlotSchema.parse(req.body);
const appointment = await appointmentsService.bookSlot(req.user.id, availabilitySlotId);
const appointment = await appointmentsService.bookSlot(auditActor(req), availabilitySlotId);
res.status(201).json(appointment);
}

Expand Down
Loading
Loading