Skip to content

System overview

What Curate is and how the running system fits together — one Worker per hostname, one backend behind them all.

Updated View as Markdown

Curate is a multi-tenant digital-memorial platform. Customers are cemetery and columbarium operators — one operator is one tenant — whose staff administer niches, memorials, and the families attached to them. Families build online tributes for the deceased: profiles, photo timelines, galleries, AI-generated oil-paintings, and encrypted “time capsule” letters delivered after passing. The primary locale is zh-TW.

Two sentences carry most of the architecture:

  1. Every browser-facing hostname is a Cloudflare Worker.
  2. The tenant is the hostnametaipei.curate.memorial is tenant taipei, and nothing else decides it.

Runtime topology

There is exactly one backend deployment. It is never reached at its own address by a browser: each Worker proxies /api/* under its own hostname, so every request a browser makes is same-origin.

flowchart TB
  subgraph edge["Cloudflare"]
    tenant["curate-tenant Worker<br/>one Custom Domain per tenant hostname<br/>/ = consumer SPA · /admin = tenant admin"]
    platform["curate-platform Worker<br/>admin.curate.memorial<br/>super-admin SPA"]
    external["curate-external Worker<br/>curate.memorial<br/>marketing + public guides"]
    internal["curate-internal Worker<br/>internal.curate.memorial<br/>this handbook · Access-gated"]
  end

  subgraph aws["AWS · ap-east-2"]
    alb["ALB · api.curate.memorial<br/>ingress locked to Cloudflare CIDRs"]
    api["ECS: API server"]
    bg["ECS: background worker<br/>polls the DB · same image"]
    store[("Postgres · S3 · KMS · Resend")]
  end

  tenant -->|"/api/* · sets X-Forwarded-Host"| alb
  platform -->|"/api/*"| alb
  alb --> api
  api --> store
  bg --> store

Tenant origins are all served by one Worker, with a Cloudflare Custom Domain per tenant hostname — no wildcard. We sell B2B and onboard tenants deliberately (roughly one a quarter), so binding a hostname is a single dashboard click on the onboarding checklist, and only real tenants resolve at all. The Worker serves the consumer SPA at /, the tenant-admin SPA at /admin, and proxies /api/* onward.

The super-admin origin is separate on purpose. Tenant staff and content contributors should never share an origin with the cross-tenant admin surface, and a separate origin can be put behind IP allowlisting or SSO on its own. Tenant origins go further and refuse /api/super* at the edge with a bare 404.

The two content Workers serve static output and make no API calls at all — which is why nothing leaves them in the diagram. internal.curate.memorial — this site — sits behind Cloudflare Access, so offboarding a maintenance vendor is a deletion from one policy.

The backend is two binaries built from one image: the API server and a background worker. Nothing reaches the background worker — it has no inbound arrow because it polls the database for jobs rather than serving requests. No Redis, no queue. Migrations run inline on boot under an advisory lock.

The tenant is the hostname

The backend reads the tenant slug from the first DNS label of the hostname the browser used, which reaches it as X-Forwarded-Host (the Worker always overwrites that header, so a client cannot supply its own).

This happens only when a session is established — login, signup, password reset. From then on the session carries the tenant_id and every later request trusts the session, never the header. Tenant isolation itself is enforced deeper in the backend, at the SQL layer; the hostname decides only which tenant a new session belongs to.

sequenceDiagram
  participant B as Browser
  participant W as curate-tenant Worker
  participant A as API
  participant D as Postgres

  B->>W: POST taipei.curate.memorial/api/consumer/auth/login
  Note over W: overwrites X-Forwarded-Host<br/>a client-supplied value cannot survive
  W->>A: proxy · X-Forwarded-Host: taipei.curate.memorial
  A->>A: first DNS label = slug "taipei"
  A->>D: resolve tenant by slug
  D-->>A: tenant_id
  A-->>B: session cookie carrying tenant_id
  Note over B,A: every later request trusts the session, never the host

Consequences you will meet in practice:

  • A host with no tenant gets 400 invalid_tenant from the auth endpoints — a bare hostname, an IP, or a first label that matches no tenant row (which is what you get hitting the API origin directly).
  • Cookies are host-only and SameSite=Lax, one per surface: consumer_jwt, admin_jwt, super_jwt, each scoped to its own API path. This is why every origin proxies the API under its own hostname — the backend sets no CORS headers, deliberately. Fix a cross-origin failure by moving the API behind the calling origin, never by adding CORS.
  • The host also gates who may log in where. A tenant hostname admits only that tenant’s staff; the platform origin admits only super admins. Mismatches return the endpoint’s ordinary response (401, or 202 for a reset request) so nothing leaks about which accounts exist.
  • Email links are per-tenant. Staff invites, memorial invites, and time-capsule letters are minted on <slug>.<base domain>. There is no environment-wide “client URL” any more.
  • Some slugs are reservedadmin, api, www, curate, staging, internal, platform — and rejected at both tenant create and rename, so a customer can never claim a hostname the platform depends on.

In local development this means browsing http://<slug>.localhost:5173. Browsers resolve *.localhost to loopback on their own, so no /etc/hosts editing is involved; bare localhost gets the same 400 invalid_tenant.

Where to go next

Navigation

Type to search…

↑↓ navigate↵ selectEsc close