Skip to content

TheraSite

Multi-tenant platform under a health-privacy regime.

Built for Ontario psychotherapists registered with CRPO: one database per tenant, and messages the server itself cannot decrypt.

TheraSite marketing site: a hero reading 'Your clients are searching for a therapist right now' over a device mockup of a therapist's site
therasite.ca, the product's own front door, and the first thing a prospective tenant sees.
At a glance
Database per tenant · Browser-side decryption · PHIPA · Cloudflare Workers + D1

The constraint that shaped the design: under PHIPA a cross-tenant query bug is a compliance violation, not a bug. So tenancy is one Cloudflare D1 database per tenant with a platform database for lookup, rather than a tenant_id column and a WHERE clause everyone has to remember. The cost is per-tenant schema migrations: every release runs N times.

1:1
Tenant to database
7
Tables per tenant DB

System Architecture

Container view
Therapists
Portal, admin, messaging
Public visitors
Marketing sites, contact forms
HTTPS
TheraSite
Angular SPA
Angular 20, standalone components, signals
MarketingPortalAdminContactsMessagesAuth
JSON over HTTPS
Cloudflare Workers
Hono router on the edge runtime
D1 binding, resolved per tenant
Cloudflare D1
Platform database for tenant lookup, then one database per tenant on an identical schema
External services
Turnstile
Bot filtering on public forms
Email Workers
Inbound mail routing
Cloudflare DNS API
Tenant domain provisioning
OpenPGP.js
Key handling in the browser
Workers hold no long-lived process, so state a server would keep in memory (sessions, key material, rate-limit counters) lives in D1 or in the browser.

Key Decisions

Security DecisionsArchitecture DecisionsBusiness Decisions
One-way door · Security

End-to-end encryption where the server holds only ciphertext: PGP per message, AES-256-GCM around the private key, PBKDF2 for key derivation

Line of thinking
PHIPA requires protecting health information at rest. The usual reading of that is server-side encryption with a server-held key, which still loses everything in a database dump. So the key never reaches the server. Inbound messages are PGP-encrypted against the therapist's public key. The private key is stored AES-GCM encrypted under a key derived from their password with 100,000 iterations. Decryption happens in the browser. The cost is permanent: a forgotten password means unrecoverable data, and there is no server-side search over message bodies.
Where plaintext exists
1
PBKDF2 key derivation
Browser
The therapist's password and a per-user salt derive an AES-256-GCM key in the browser. The password itself is never stored.
SHA-256Per-user salt256-bit key
Derived key wraps the private key
2
Key storage
AES-GCM
The PGP private key is written to the tenant's D1 database AES-GCM encrypted under the derived key. The Worker stores it without being able to open it.
Per-tenant D1Wrapped private key
Public key is left readable
3
Inbound encryption
OpenPGP.js
A contact form submission is PGP-encrypted against the tenant's public key at the edge, before the first write. No plaintext row ever exists.
Encrypt on writeCiphertext at rest
Therapist signs in
4
Client-side decryption
Browser
The password re-derives the AES key, unwraps the private key in memory, and decrypts message bodies in the browser.
Key never leaves the tabServer sees ciphertext only
Nothing on the server can read a message body, which also means nothing on the server can index one or recover it after a forgotten password. Search and account recovery are the features this design gives up.
1 / 7
Inbound messages are PGP-encrypted to the therapist's public key and unwrapped in the browser, so the server stores ciphertext it holds no key for. The cost lands on password recovery: a forgotten password means re-keying, and prior messages stay unreadable.