Skip to content

04 Backend Architecture

Anubha Parashar edited this page Oct 10, 2026 · 1 revision

The HealthBook AI backend is a single FastAPI application (internal code name: BabyCare) that serves the JSON API under /api and, when the web/ directory is present, the static web client at /. Building api/main.py does all the startup work: it migrates the schema, locks down the database, prepares the private document store and installs the middleware. It then includes twelve feature routers. A separate worker process delivers reminders. This page covers how the pieces fit together; the domain rules live on the feature pages.

Responsibilities

  • Expose one HTTP API for the web app and the Android app. The mobile client sends a bearer token; the web client uses a session cookie plus a CSRF header.
  • Enforce transport guardrails (origin check, rate limit, request size, security headers) before any handler runs.
  • Authenticate every protected request, then check that the caller owns the profile or record, or has caregiver access to it.
  • Keep clinical text encrypted at the application layer and original files encrypted in a private store.
  • Bring the schema to the Alembic head on every start of the API and the worker.

Inputs and outputs

Input Output Notes
HTTPS request to /api/... JSON, PDF, ZIP, CSV or image bytes All responses get Cache-Control: no-store
Authorization: Bearer <token> (mobile) Authenticated User Token is looked up by SHA-256 hash
babycare_session cookie + X-Requested-With: BabyCare (web) Authenticated User CSRF header required on non-GET
X-Scheduler-Key header One reminder worker tick /api/internal/* only
X-Camera-Worker-Key header Camera config / event ingest Home camera worker only
Multipart upload Encrypted object + documents row 12 MB, 35 pages, magic-byte type check

Application composition

api/main.py runs these steps in order when it is imported:

  1. Creates FastAPI(title='HealthBook AI — local prototype').
  2. Calls migrate.upgrade(), which takes a file lock and runs Alembic to head.
  3. Calls cloud.harden_database(engine). On PostgreSQL this enables RLS on every table and revokes the Supabase client roles. On SQLite it does nothing.
  4. Calls cloud.prepare_storage(), which creates or verifies the private bucket when the Supabase backend is selected.
  5. Calls cloud.install_handlers(app), which maps MissingObject to 404 and StorageError to 503 without leaking object names.
  6. Registers guardrails.secure_headers as HTTP middleware. If BABYCARE_CORS_ORIGINS is set, it also adds CORSMiddleware with an explicit origin list, credentials allowed, and the headers Authorization, Content-Type, X-Requested-With and X-Share-Pin.
  7. Defines the core routes in main.py itself: auth, profiles, documents, medicines, visits, vaccines, observations, reminders, today, timeline, search, doctor summary and health.
  8. Includes the routers in this order: workflows, providers, history_api, lifetime_api, cloud, share, care_api, camera_api, export_api, vitals_api, profiles_api, password_reset.
  9. Mounts StaticFiles(web/, html=True) at /.

All routers use prefix='/api', except password_reset, which uses /api/auth/password-reset. In care_api, the generic /care/{kind} routes are registered last so that specific paths such as /care/day and /care/settings match first.

Request flow

flowchart TD
    C["Web or Android client"] -->|"HTTPS"| CORS["CORSMiddleware<br/>explicit origins only"]
    CORS --> G["secure_headers middleware"]
    G --> O{"Unsafe method with<br/>foreign Origin?"}
    O -->|"yes"| R403["403 Origin not allowed"]
    O -->|"no"| RL{"rate_allowed per IP<br/>auth 30 per min, api 600 per min"}
    RL -->|"exceeded"| R429["429 with Retry-After 60"]
    RL -->|"ok"| SZ{"Content-Length over 13 MiB?"}
    SZ -->|"yes"| R413["413 Request too large"]
    SZ -->|"no"| AUTH["require_user<br/>bearer or cookie plus CSRF header"]
    AUTH -->|"invalid or expired"| R401["401"]
    AUTH --> OWN["own_patient or own_resource<br/>owner or caregiver grant"]
    OWN -->|"no grant"| R404["404 Not found"]
    OWN -->|"viewer writing"| R403b["403 Read-only caregiver"]
    OWN --> H["Route handler<br/>Pydantic validation"]
    H --> DB[("SQLAlchemy session<br/>EncryptedText columns")]
    H --> ST[("storage.py<br/>local dir or private bucket")]
    H --> AU["audit row in same transaction"]
    H --> RESP["Response plus security headers"]
Loading

Middleware (api/guardrails.py)

Control Behaviour
Origin check On /api/* requests that are not GET, HEAD or OPTIONS and carry an Origin, the origin must equal the API's own base URL or be listed in BABYCARE_CORS_ORIGINS. Otherwise the request gets 403.
Health bypass /api/health skips rate limiting, so it never touches the database.
Rate limit rate_allowed(key, limit, seconds) keys on auth:<ip> (30 per 60 s) for /api/auth/* and on api:<ip> (600 per 60 s) for everything else. The key is SHA-256 hashed before storage. The check runs in the threadpool so it cannot block the event loop.
Store: db (default) One rate_windows row per key. A conditional UPDATE ... WHERE count < limit makes the increment atomic. A first-insert race is retried after IntegrityError. Counts persist across restarts and are shared across instances.
Store: memory BABYCARE_RATE_LIMIT_STORE=memory gives a fixed window per process under a lock, with no database write per request. It suits the single free instance. Restarts reset the counts, which only makes the limiter more lenient. The map is cleared once it passes 50,000 keys.
Retry-After A 429 response carries Retry-After: 60 and a generic message.
Request size A Content-Length over 13 MiB gets 413, and a non-numeric one gets 400. Upload handlers also cap the body they read at 12 MB.
Headers X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Cache-Control: no-store, X-Frame-Options: DENY, Permissions-Policy: camera=(self), microphone=(), geolocation=(), plus a CSP: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. HSTS (one year, includeSubDomains) is added only when BABYCARE_ENV=production.

Other modules call the same rate_allowed for narrower limits. For example, a password reset request is limited to 3 per hour per email hash.

Dependency injection (api/security.py, api/database.py)

  • get_db() yields a SessionLocal() session with autoflush=False and expire_on_commit=False. On PostgreSQL the pool is sized for the free pooler (default 5 + 5 overflow, recycled every 900 s, TLS sslmode=require, no server-side prepared statements).
  • require_user accepts a bearer token or the babycare_session cookie. Cookie requests that are not GET, HEAD or OPTIONS must send X-Requested-With: BabyCare, or they get 403. The token is matched by SHA-256 against sessions.token_hash and its expiry is checked. The dependency also marks user._write_access: true for unsafe methods, except paths ending in /ask or /signed-access.
  • own_patient(db, user, pid) returns the profile when the caller is its owner or holds a caregiver_access grant. A missing grant returns 404, so the API does not confirm that the profile exists. A viewer grant that attempts a write returns 403.
  • own_resource(db, user, Model, id) loads any row that has a patient_id and applies own_patient to it.
  • workflows.guardian() adds a primary-guardian check (owner only) for sharing, invitations, the audit log, export and AI vision consent.

See Family Profile Isolation and Authentication and Session Security.

Migrations on startup (api/migrate.py)

upgrade() takes FileLock(DATA_DIR/migration.lock, timeout=60), so the API and the worker never migrate at the same time. On SQLite, if the database has patients and is not at head, it first writes an encrypted snapshot named before-migration-<UTC timestamp>.sqlite.enc to the data directory. The snapshot is serialized in memory and then Fernet-encrypted. Alembic then runs upgrade head. Downgrades in the migrations raise errors on purpose; recovery means restoring an encrypted backup. See Database and Data Model.

Worker (api/worker.py)

python -m api.worker migrates, takes reminder-worker.lock, and on PostgreSQL also takes pg_try_advisory_lock, so only one worker runs. It calls tick() every 15 s; with BABYCARE_WORKER_ONCE=1 it runs once and exits. A tick does three things:

  1. Reconciles confirmed plans into reminders and notifications for every profile.
  2. Delivers up to 100 due push notifications through Expo or Web Push (VAPID), always with the same generic sentence.
  3. Updates worker_heartbeat.

Retries back off exponentially, up to 6 attempts. Receipts are polled, so an accepted push is never sent twice. On free hosting, where the instance sleeps, an external scheduler calls POST /api/internal/scheduler/tick with X-Scheduler-Key to run the same tick(). A reminder is never proof that a dose was given. See Notifications and Reminder Architecture.

Storage abstraction (api/storage.py)

get_storage() picks LocalStorage (DATA_DIR/encrypted_files, files opened with O_EXCL and mode 0600) or SupabaseStorage (a private bucket reached only with the service-role key, with three retries on transport errors), according to BABYCARE_STORAGE. Callers use write_document, read_document, delete_document, document_exists and list_documents. Callers always pass bytes that are already encrypted, and object names are random <48 hex>.enc strings. In production, local storage is refused unless BABYCARE_ALLOW_LOCAL_STORAGE=true. See Encryption and Private Storage.

Module map

Module Responsibility
__init__.py Package marker
main.py App factory, startup sequence, core routes (auth, profiles, documents, medicines, vaccines, visits, observations, reminders, timeline, search)
config.py Environment, DATA_DIR, DATABASE_URL normalisation, production refusals
database.py Engine, pool options, SQLite FK pragma, SessionLocal, get_db
models.py Core ORM tables (users through record revisions)
care_models.py Daily Care journal, stool photos and intake, camera tables
lifetime_models.py Fever episodes, antibiotic courses and administrations, evidence, parent entries
share_models.py Share packages and access log
vitals_models.py Vitals journal rows
security.py PBKDF2 password hashing, session issue, require_user, own_patient, own_resource
guardrails.py Origin check, rate limiting, size limit, security headers
crypto.py Fernet key handling and the EncryptedText column type
storage.py Local and private Supabase blob backends
cloud.py RLS lock-down, bucket preparation, error handlers, scheduler tick, internal status, web push subscriptions
migrate.py Locked Alembic upgrade with encrypted pre-migration snapshot
worker.py Reminder reconciliation and push delivery loop
reminders.py Confirmed plans to persisted reminders and notifications
push_web.py VAPID Web Push with a fixed generic body
mailer.py Transactional email over HTTPS providers
password_reset.py Reset token table and /api/auth/password-reset/* routes
workflows.py Review/confirm, signed file access, admissions, visit series, administrations, episodes, growth, family, notifications, devices, audit, export, account deletion
review_validation.py Field-level validation for "Confirm and save to history"
extract.py Local OCR and medication candidates
extraction.py Page-linked structured candidates and document classification
vaccination.py Reference-only schedule charts and card date parsing
providers.py Opt-in external vision transcription with per-document consent
history.py remember(), which writes the previous values to record_revisions
history_api.py Dashboard, history, medication, vaccination and lab series, thumbnails, document back-links
intelligence.py Extractive "Ask" answers grounded in authorized records
reporting.py Source-labelled timeline additions and the text summary
lifetime.py, lifetime_api.py Lifetime fever and antibiotic analysis and its API
care.py, care_api.py Daily Care engine and API
care_vision.py Descriptive stool-image observations
stool_intake.py Capture-time detection for stool photos
vitals.py, vitals_api.py Vitals validation, summaries and API
profiles.py, profiles_api.py Age groups, per-profile modules, profile API
share.py Share with Doctor scopes, ZIP and PDF, doctor links
export_model.py, export_pdf.py, export_api.py Record export model, PDF renderer, API
camera.py, camera_api.py, camera_worker.py Camera helpers, API, local worker
datasync.py Dialect-neutral raw table copy for migration and backup
backup.py Encrypted SQLite backup and restore CLI

Security boundaries

  • Every handler that reads profile data calls own_patient or own_resource before it touches records.
  • The source-document checks (safe_doc) reject a document that belongs to another profile (400) or a page outside the original (422).
  • as_json never serialises password_salt, password_hash, token_hash, extracted_cipher, storage_name or sha256.

Implementation

api/main.py, api/guardrails.py, api/security.py, api/database.py, api/migrate.py, api/worker.py, api/storage.py, api/cloud.py

Related pages

System Architecture · API Design · Database and Data Model · Security and Privacy Model · Deployment Architecture

Clone this wiki locally