---
title: "System overview"
description: "What Curate is and how the running system fits together — one Worker per hostname, one backend behind them all."
---

> Documentation Index
> Fetch the complete documentation index at: https://internal.curate.memorial/llms.txt
> Use this file to discover all available pages before exploring further.

# System overview

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 hostname** — `taipei.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.

```mermaid
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.

```mermaid
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 reserved** — `admin`, `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

- [Repo tour](/architecture/repos/) — which of the four repos holds what
- [Environments](/architecture/environments/) — hostnames, deploy paths, and the staging tenant
- [`curate-server` `docs/ARCHITECTURE.md`](https://gitlab.com/revere-curate/curate-server/-/blob/main/docs/ARCHITECTURE.md)
  — backend internals: layering, RBAC, isolation, domain model

Source: https://internal.curate.memorial/architecture/index.mdx
