Open-source patient data portability for hospitals and clinics
Self-host. Pull from your HIS. Export FHIR R4. Generate AI summaries. Zero PHI persisted.
English • Tiếng Việt • 한국어 • 日本語
Quickstart • Features • Regional support • API • CLI • Self-host • Security
Patient medical records are locked inside hospital information systems (HIS). In Vietnam, 34M+ VneID records lack portability. In Japan, data is siloed per facility with limited interoperability. FHIRBridge is the bridge: connect to any HIS via FHIR API or CSV/Excel, transform into standardized FHIR R4 bundles, and (optionally) generate de-identified AI summaries — all running on your own infrastructure.
No SaaS. No hosted tier. No billing. No quotas. You pull the repo, you run it. The hospital, clinic, or research group is the operator and the data controller.
Pick one. Both give you the web UI at http://localhost:8080 with a synthetic Demo HIS (fake patients from Vietnam, Korea, Japan and the US), so you can try a full export without a real hospital system or internet access.
Requires Node.js ≥ 22 (24 LTS recommended) and pnpm ≥ 9 (corepack enable installs pnpm).
git clone https://github.com/Digital-Healthcare-OpenSource/FHIRBridge.git
cd FHIRBridge
pnpm install
pnpm run setup # creates .env (random secrets + an API key) and builds everything
pnpm demo # API + web UI + Demo HIS → http://localhost:8080
pnpm run setup, notpnpm setup— the latter is a built-in pnpm command.
git clone https://github.com/Digital-Healthcare-OpenSource/FHIRBridge.git
cd FHIRBridge
# Create .env with random secrets + an API key (uses a throwaway Node container)
docker run --rm -v "$PWD":/app -w /app node:24-alpine node scripts/setup.mjs --env-only
docker compose --profile demo up --build # → http://localhost:8080Drop --profile demo to run without the Demo HIS.
- Open http://localhost:8080 → Settings, paste your API key — the value of the
API_KEYS=line in.env(the setup step never prints it) — and click Save Settings. The key stays in browser memory only. - Pick your language (Tiếng Việt / English / 日本語 / 한국어) from the language switcher.
- Export → FHIR Endpoint → server URL
pnpm demo:http://localhost:8090/fhir- Docker demo profile:
http://demo-his:8090/fhir
- Patient ID:
demo-vn-001,demo-kr-001,demo-jp-001ordemo-en-001→ start the export → download the FHIR R4 Bundle (JSON or NDJSON). - Import → pick your country's example column mapping → Try with sample data → Import → download the FHIR Bundle built from a CSV / Excel export.
Check an installation from the command line at any time with pnpm smoke (health → HIS
connection → export → download; Docker demo: pnpm smoke --his http://demo-his:8090/fhir).
SSRF protection blocks private and loopback addresses by default — and your HIS almost
certainly lives on the hospital network. List it explicitly in .env, then run pnpm start
(or docker compose up):
# hostnames, IPv4 addresses, IPv4 CIDRs or host:port pairs, comma-separated
CONNECTOR_ALLOWED_HOSTS=his.hospital.local,10.20.0.0/16Cloud-metadata addresses (169.254.0.0/16, metadata.google.internal, …) stay blocked even if
listed. For CSV / Excel exports from your HIS, see Column mappings.
- FHIR R4 Export — Patient, Encounter, Condition, Observation, MedicationRequest, AllergyIntolerance, Procedure, DiagnosticReport, Immunization, CarePlan, CareTeam, Specimen, DocumentReference, Practitioner, Medication
- HIS Connectors — FHIR endpoint (SMART on FHIR / OAuth2) + CSV / Excel import with JSON column mappings
- AI Summaries (optional) — Claude or OpenAI providers, de-identified before any external call (HMAC-SHA256 + date shifting), summaries in VI / EN / JA / KO
- Three interfaces — CLI tool, REST API (Fastify), React web dashboard in VI / EN / JA / KO
- Privacy-by-design — Stream-only architecture, no PHI persisted to durable storage, audit log stores hashes only
- IPS Bundle support — International Patient Summary
Bundle.type=documentprofile - Runs anywhere — one-command setup, Docker Compose, or a single Node.js process; works offline / air-gapped (no third-party fonts, CDNs or telemetry)
| 🇻🇳 Vietnam | 🇰🇷 Korea | 🇯🇵 Japan | 🌐 International | |
|---|---|---|---|---|
| Web UI language | Tiếng Việt | 한국어 | 日本語 | English |
| AI summary language | vi |
ko |
ja |
en |
| Column-mapping example | csv-vneid-vn.json | csv-korea-hospital.json | excel-japan-clinic.json | csv-generic-hl7.json |
| National ID handling | VneID identifiers stay on your servers | RRN (주민등록번호) HMAC-hashed / masked at ingest | — | — |
| Compliance notes | PDPD (Decree 13/2023) | PIPA, AUDIT_PROFILE=kr access log |
APPI | Privacy & security |
The UI language is detected from the browser (unsupported languages fall back to English) and can be changed at any time from the language switcher; the choice is remembered per browser.
TypeScript (strict, ES2022) · Turborepo + pnpm workspaces · Fastify 5 · Vite 7 + React 18 + Tailwind · Commander.js · Vitest + Playwright · PostgreSQL 16 (audit logs only, no PHI) · Redis 7 (rate limit + caching, optional) · Anthropic SDK · OpenAI SDK · i18next (VI / EN / JA / KO)
fhirbridge/
├── packages/
│ ├── types/ FHIR R4 types, AI types, connector types
│ ├── core/ FHIR engine, validators, connectors, AI pipeline, security utilities
│ ├── api/ Fastify REST server (JWT / API key, rate limit, audit, helmet, swagger)
│ ├── cli/ Commander.js CLI tool
│ └── web/ Vite + React + Tailwind dashboard (i18n VI/EN/JA/KO)
├── scripts/ setup.mjs (one-command setup), start.mjs (`pnpm start` / `pnpm demo`)
├── examples/ column mappings for VN / KR / JP / generic HIS exports, synthetic Demo HIS
├── docker/ web image (nginx), Postgres 16 + Redis 7 for persistent audit / multi-replica
├── docker-compose.yml API + web UI (+ Demo HIS with --profile demo)
└── tests/ integration, E2E (CLI + Playwright), security, performance
With pnpm demo (or the Docker demo profile) running:
API=http://localhost:8080/api/v1
KEY=$(grep '^API_KEYS=' .env | cut -d= -f2 | cut -d, -f1)
HIS=http://localhost:8090/fhir # Docker demo profile: http://demo-his:8090/fhir
# 1. Probe the HIS
curl -s -X POST $API/connectors/test -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d "{\"type\":\"fhir-endpoint\",\"config\":{\"baseUrl\":\"$HIS\"}}"
# → {"connected":true,"serverVersion":"4.0.1",...}
# 2. Start an export
EXPORT_ID=$(curl -s -X POST $API/export -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d "{\"patientId\":\"demo-vn-001\",\"connectorConfig\":{\"type\":\"fhir-endpoint\",\"baseUrl\":\"$HIS\"}}" \
| jq -r .exportId)
# 3. Poll, then download the FHIR R4 Bundle
curl -s $API/export/$EXPORT_ID/status -H "X-API-Key: $KEY"
curl -s "$API/export/$EXPORT_ID/download?format=json" -H "X-API-Key: $KEY" -o bundle.json
# 4. (Optional, needs ANTHROPIC_API_KEY or OPENAI_API_KEY on the server) AI summary in Vietnamese
curl -s -X POST $API/summary/generate -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d "{\"exportId\":\"$EXPORT_ID\",\"summaryConfig\":{\"language\":\"vi\",\"provider\":\"claude\"}}"JWT (HS256, signed with JWT_SECRET, sub + exp claims required) works too, via
Authorization: Bearer <jwt>. The OpenAPI UI is served at /api/v1/docs when ENABLE_DOCS=true.
pnpm install
pnpm run setup --no-build # .env only
pnpm build # build all packages (the API dev server runs from dist/)
pnpm --filter @fhirbridge/api dev # API → http://localhost:3001
pnpm --filter @fhirbridge/web dev # Web → http://localhost:5173 (proxies /api to :3001)
pnpm test # unit tests (no Docker needed)
pnpm typecheck # TypeScript strict check
pnpm lint # ESLint
pnpm format:check # Prettier
# Extended test suites
pnpm test:integration # Fastify server.inject() integration tests
pnpm test:e2e:cli # CLI as real subprocess
pnpm test:e2e # Playwright (needs Docker + dev servers running)
pnpm test:security # XSS, SSRF, IDOR, JWT bypass
pnpm test:perf # Latency + memory + CSV scaling
pnpm test:a11y # axe-core via Playwright| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/export |
Initiate patient data export |
GET |
/api/v1/export/:id/status |
Check export progress |
GET |
/api/v1/export/:id/download |
Download FHIR R4 Bundle (?format=json or ?format=ndjson) |
POST |
/api/v1/connectors/test |
Test HIS connection |
POST |
/api/v1/connectors/import |
Upload CSV / Excel file + column mapping |
POST |
/api/v1/summary/generate |
Generate AI summary from exportId or an inline bundle (needs an AI provider key) |
GET |
/api/v1/summary/:id/download |
Download summary (Markdown / JSON) |
POST |
/api/v1/consent/record |
Record cross-border AI consent |
POST |
/api/v1/auth/logout |
Revoke the caller's JWT (jti) and audit the sign-out |
GET |
/api/v1/health |
Liveness + dependency health |
Authentication: X-API-Key: <key> (keys from API_KEYS) or Authorization: Bearer <jwt>. /api/v1/health is public.
From the repo root (after pnpm run setup), prefix commands with pnpm fhirbridge:
# Export from a FHIR endpoint (private / localhost addresses must be allowlisted)
CONNECTOR_ALLOWED_HOSTS=localhost:8090 \
pnpm fhirbridge export --patient-id demo-vn-001 --endpoint http://localhost:8090/fhir --output bundle.json
# Import CSV / Excel into a FHIR Bundle using a column mapping
pnpm fhirbridge import --file examples/data/vn-hospital.csv --mapping examples/column-mappings/csv-vneid-vn.json --output bundle.json
# Validate a FHIR Bundle
pnpm fhirbridge validate --input bundle.json
# AI summary (de-identified before the API call; needs ANTHROPIC_API_KEY or OPENAI_API_KEY)
export ANTHROPIC_API_KEY=...
pnpm fhirbridge summarize --input bundle.json --provider claude --language vi
# Same summary as a PDF (Vietnamese / Korean / Japanese text needs a Unicode font file;
# the web UI's "Print / Save as PDF" needs none)
pnpm fhirbridge summarize --input bundle.json --language vi --format pdf --output summary.pdf --pdf-font /path/to/font.ttf
# Saved connection profiles
pnpm fhirbridge config add-profile my-hospital
pnpm fhirbridge config listRun pnpm fhirbridge <command> --help for every option. The CLI reads settings from its
environment (not from .env): export CONNECTOR_ALLOWED_HOSTS / provider keys in your shell.
| Setup | Command | Audit log | Rate limit / caches |
|---|---|---|---|
| Single machine, Node.js | pnpm start |
stdout | in-memory (1 instance) |
| Single machine, Docker | docker compose up -d --build |
container stdout | in-memory (1 instance) |
| Persistent audit + multi-node | API with DATABASE_URL + REDIS_URL set |
PostgreSQL | Redis (shared) |
For PostgreSQL + Redis, start docker/docker-compose.yml (passwords come from .env), uncomment
DATABASE_URL / REDIS_URL in .env (the setup script already filled in matching passwords),
and apply migrations:
set -a; . ./.env; set +a # export POSTGRES_PASSWORD / REDIS_PASSWORD
docker compose -f docker/docker-compose.yml up -d
pnpm --filter @fhirbridge/api migrate
pnpm startBehavior under degraded infra:
- No
DATABASE_URLset → audit log writes to stdout (Console sink). Usejournalctl/ log aggregator. - No
REDIS_URLset → rate limit + caches stay in-memory per process. Single-replica only. - No
ANTHROPIC_API_KEY/OPENAI_API_KEY→ summary generation answers503with the variable to set; export + connector endpoints unaffected.
CI publishes both images to GHCR on every push to main (latest) and for each release tag
(<version>), multi-arch (amd64 + arm64), Trivy-scanned, cosign-signed with SBOM + provenance:
ghcr.io/digital-healthcare-opensource/fhirbridge-api and …/fhirbridge-web. The root
docker-compose.yml already uses these names, so you can skip the build:
docker compose pull && docker compose up -d # latest main
FHIRBRIDGE_VERSION=<version> docker compose pull # or pin a release ≥ 0.3.0 (then `up -d` with the same variable)If pull answers unauthorized, the packages are not public for your account — build from
source with docker compose up -d --build instead.
FHIRBridge handles PHI in transit. TLS is REQUIRED in production — never expose
:3001 directly. Terminate TLS at a reverse proxy and run the API on loopback / a
private network behind it. The operator is the data controller; these docs cover the
operational contract:
- Reverse proxy & TLS — Caddy (automatic HTTPS)
and nginx examples,
TRUST_PROXY, and the streaming-critical settings (disable response buffering, long read timeouts) needed for NDJSON exports. - Backup & restore — nightly
pg_dumpof the audit DB, retention per jurisdiction (US ~6 yr, VN/JP per local rule), the append-only purge path, and a restore drill. - Upgrading —
init.sqlruns on first boot only; apply schema changes to an existing database with the built-in runner:pnpm --filter @fhirbridge/api migrate(DDL-capableDATABASE_URL, idempotent, checksum drift detection, advisory-locked for multi-replica). - Scaling — single-replica by default; running
multiple replicas requires
REDIS_URL(exports, summaries, idempotency, and rate limiting are otherwise per-process), plus per-replica/metricsscraping.
Pin production deployments to an image digest (not :latest), and verify the
cosign signature / SBOM / provenance attestations published with each release.
| Protection | Implementation |
|---|---|
| Zero PHI at rest | Stream-only export pipeline; in-memory record TTL 10 min |
| De-identification | HMAC-SHA256 + per-patient deterministic date shift before AI call |
| Safe Harbor age cap | birthDate removed when computed age ≥ 89 (HIPAA §164.514(b)(2)(i)(C)) |
| SSRF protection | Blocks private IPs, link-local, IPv6 loopback, cloud metadata; internal HIS hosts opt-in via CONNECTOR_ALLOWED_HOSTS (metadata never) |
| IDOR protection | Ownership verified on every export / summary access; cross-tenant attempts audited as 404 |
| Authentication | JWT (HS256) + API key with crypto.timingSafeEqual comparison |
| Rate limiting | Per-user / per-IP, 100 req/min default (configurable via RATE_LIMIT_PER_MINUTE) |
| Audit logging | HMAC-SHA256 hashes of user IDs, action types, resource counts only — never raw identifiers |
| Cross-border consent | Per-session consent recording before sending data to non-domestic AI providers |
| BAA disclaimer | Hospital operator owns the BAA decision; UI surfaces the disclaimer for end users |
| HMAC secret reuse | Boot fails if HMAC_SECRET == JWT_SECRET (Zod-enforced) |
The openai provider talks to any server that implements the OpenAI Chat Completions API
(POST <base>/chat/completions), so the summary model can run in-country or inside the
hospital network instead of at a foreign provider:
AI_PROVIDER=openai
OPENAI_BASE_URL=http://10.20.0.5:8000/v1 # your server's OpenAI-compatible base URL
OPENAI_MODEL=<model-name-your-server-serves>
OPENAI_API_KEY=<the key your server expects; any non-empty value if it checks none>ANTHROPIC_BASE_URL does the same for Claude requests (for example an API gateway that
speaks the Anthropic Messages API). The data is still de-identified before it is sent, and
fhirbridge summarize names the configured host in its data-transfer warning. FHIRBridge
cannot tell where a server physically runs — whether a setup counts as a cross-border
transfer is for your DPO to decide (see the country notes below).
Under Japan's APPI, pseudonymized patient data (HMAC-hashed IDs, shifted dates) is still personal information, and sending 要配慮個人情報 (special care-required personal information) to an AI provider hosted outside Japan generally requires explicit per-patient consent naming the destination country (Art. 28).
Recommendation for Japanese deployments: run with AI summaries disabled (simply
omit ANTHROPIC_API_KEY / OPENAI_API_KEY — export and connectors are unaffected),
or keep inference in-country — through your provider contract or a model you host yourself
(AI summaries without leaving your network).
The built-in consent recording captures operator consent, not patient consent — the
operator remains the data controller. This is engineering guidance, not legal advice.
Under Korea's PIPA, transferring personal information overseas requires consent that discloses five items (Art. 28-8): the data being transferred, the destination country and transfer method, the recipient's name and contact, the recipient's purpose of use and retention period, and how to refuse plus the consequences of refusal. FHIRBridge's consent modal presents all five items, and the consent API rejects a Korean-market grant that does not acknowledge all five. The resident registration number (주민등록번호) is additionally protected: RRN values detected at ingest are HMAC-hashed or masked and never flow through the pipeline raw (Art. 24-2).
Access log (접속기록): set AUDIT_PROFILE=kr to enrich every audit row with
patientRefHash (HMAC of the accessed patient id — never raw) and sourceIp,
per the KR 안전성 확보조치 access-log requirement (who / when / whose record /
from where). Keep audit rows ≥ 2 years: set AUDIT_RETENTION_DAYS=730 (or
more) and the API runs a daily purge_audit_logs() purge in-process (apply
migration 002 via pnpm --filter @fhirbridge/api migrate so the least-privilege
role may execute it). Leave it unset to manage retention yourself — the table is
append-only outside that function; e.g. schedule
SELECT purge_audit_logs(INTERVAL '2 years'); via pg_cron. Review the log
periodically. Note: sourceIp is personal data under GDPR —
the field only exists under the KR profile; leave AUDIT_PROFILE unset elsewhere.
Recommendation for Korean deployments: as with Japan, prefer running with AI summaries disabled or with an in-country/self-hosted provider (how). The built-in consent recording captures operator consent, not patient consent — your DPO decides the patient-consent process. This is engineering guidance, not legal advice.
Under Vietnam's Personal Data Protection Decree (Nghị định 13/2023/NĐ-CP), health status and medical-record information is sensitive personal data (dữ liệu cá nhân nhạy cảm, Art. 2). Processing it requires explicit, affirmative consent (Art. 11 — silence is not consent), and the patient must be told the data being processed is sensitive. The decree has no GDPR-style pseudonymization carve-out, so treat HMAC-hashed IDs and shifted dates as still-personal data: sending them to an AI provider hosted outside Vietnam is a cross-border transfer, and the operator must prepare a transfer impact assessment dossier (hồ sơ đánh giá tác động chuyển dữ liệu cá nhân ra nước ngoài, Art. 25) — alongside the general processing impact assessment (Art. 24) — and file it with the Ministry of Public Security (A05) within 60 days of the transfer commencing. Since 2026-01-01 the Personal Data Protection Law (Luật Bảo vệ dữ liệu cá nhân) sits above the decree; confirm the current filing mechanics with counsel.
Data localization (Nghị định 53/2022/NĐ-CP): the Cybersecurity Law's
data-localization rules are satisfied by design in a self-hosted deployment — the
pipeline runs entirely on your own infrastructure, exports stream from the HIS to
the client with zero PHI at rest, and VneID identifiers in CSV imports
(examples/column-mappings/csv-vneid-vn.json)
never leave your servers. The only payload that can cross the border is the
optional AI summary, and it is de-identified first (identifiers HMAC-hashed,
names redacted to [PATIENT]/[PROVIDER], dates deterministically shifted). If
your organization falls under Decree 53's log-retention duties, set
AUDIT_RETENTION_DAYS at or above the applicable floor (the API then purges
daily via purge_audit_logs(); unset = no automatic deletion, the table is
append-only otherwise) — audit rows contain HMAC hashes and counts only, never
raw identifiers or RRN/VneID values.
Recommendation for Vietnamese deployments: as with Japan and Korea, prefer
running with AI summaries disabled (omit ANTHROPIC_API_KEY / OPENAI_API_KEY —
export and connectors are unaffected) or an in-country/self-hosted provider. If
you enable a foreign provider, the consent modal (Vietnamese-first UI) discloses
the destination, provider and contact, purpose, data categories, and retention
before every summary call — but it records operator consent, not patient consent,
and consent does not replace the Art. 25 dossier: your DPO owns the
patient-consent process and the MPS filing. This is engineering guidance, not
legal advice.
pnpm test # unit tests: types, core, api, cli, web
pnpm test:integration # API integration (Fastify inject)
pnpm test:e2e:cli # CLI as a real subprocess
pnpm test:security # XSS, SSRF, IDOR, JWT, upload, RRN maskingCI (.github/workflows/ci.yml) runs build, typecheck, lint, a
production dependency audit, unit, integration, CLI E2E and security tests, plus CodeQL and a
gitleaks secret scan, on every push and pull request to main. The Playwright suites
(pnpm test:e2e, pnpm test:a11y) run locally.
pnpm run setup writes a working .env; see .env.example for every option.
| Variable | Required | Description |
|---|---|---|
JWT_SECRET |
Yes | JWT signing key (>= 32 chars, no placeholder text) |
HMAC_SECRET |
Yes | De-identification HMAC key (>= 32 chars, must differ from JWT_SECRET) |
API_KEYS |
No | Comma-separated static API keys (web UI Settings / X-API-Key header) |
PORT / HOST |
No | API listen address (default 3001 / 127.0.0.1; Docker uses 0.0.0.0 in the container) |
WEB_PORT / WEB_HOST |
No | Web UI address for pnpm start and Docker (default 8080 / 127.0.0.1) |
CONNECTOR_ALLOWED_HOSTS |
No | Internal HIS hosts to allow through SSRF protection (hostnames, IPv4, CIDRs, host:port) |
CORS_ORIGINS |
No | Comma-separated allow-list (only needed when the UI is served from another origin) |
DATABASE_URL |
No | PostgreSQL connection for persistent audit logs |
REDIS_URL |
No | Redis connection for distributed rate limit + caches |
AUDIT_PROFILE |
No | kr = Korean access-log fields (patientRefHash, sourceIp) |
AUDIT_RETENTION_DAYS |
No | Opt-in daily purge of audit rows older than N days (KR needs >= 730) |
AI_PROVIDER |
No | Default summary provider when a request does not name one: anthropic or openai |
ANTHROPIC_API_KEY |
For AI | Claude API key |
ANTHROPIC_MODEL |
No | Claude model (default claude-opus-5) |
OPENAI_API_KEY |
For AI | OpenAI API key |
OPENAI_MODEL |
No | OpenAI model (default gpt-4o) |
ANTHROPIC_BASE_URL |
No | Endpoint for Claude requests, e.g. an API gateway (default https://api.anthropic.com) |
OPENAI_BASE_URL |
No | Endpoint for openai requests — any OpenAI Chat Completions–compatible server |
RATE_LIMIT_PER_MINUTE |
No | Override the default 100 req/min budget |
METRICS_BEARER_TOKEN |
No | Bearer token (>= 16 chars) for /metrics; off when unset |
TRUST_PROXY |
No | true, a CIDR or loopback when running behind a reverse proxy |
ENABLE_DOCS |
No | true exposes the OpenAPI UI at /api/v1/docs |
Empty values (KEY=) are treated as unset.
Contributions are welcome — translations (VI / KO / JA review by native speakers is especially valuable), HIS column mappings, connectors and bug reports. Start with CONTRIBUTING.md and the Code of Conduct. Report security issues privately as described in SECURITY.md.
pnpm build && pnpm test && pnpm typecheck && pnpm lint