19 KiB
Client Area — architecture
Phase: P0
Service: platform-api (black:3060, NestJS)
DB: platform.db (black:25437)
SPA host: vps-0 (Caddy, no primary state)
SMS dispatch: mac-sync on plum via outreach.scheduled_send
v2 reference: codebase/@features/api/src/ in lilith-platform.live — entities: vip-quote, vip-client, otp-attempt, payment-method; surfaces: auth/cocotte.ts, vip/quotes.ts; middleware: app/middleware/cocotte-session.ts
Data model
All tables live in platform.db (black:25437). Migrations in infrastructure/sql/migrations/.
clients
The client registry. Scoped per provider; same phone can appear across multiple providers without collision.
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT NULL,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT clients_phone_uniqueness UNIQUE (provider_id, phone_e164)
);
CREATE INDEX idx_clients_provider ON clients (provider_id);
CREATE INDEX idx_clients_org ON clients (org_id) WHERE org_id IS NOT NULL;
org_id is set when the provider authored the client record from an org-active session. Null means Person-scope only.
documents
Covers all doctypes: quote, invoice, and future types. The doc_no is a per-(client_id, doctype) sequence starting at 1; it is assigned at INSERT via COALESCE(MAX(doc_no), 0) + 1 within a transaction.
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
doctype TEXT NOT NULL,
doc_no INTEGER NOT NULL,
doc_year INTEGER NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
version INTEGER NOT NULL DEFAULT 1,
presentation JSONB NOT NULL DEFAULT '{}',
payment_methods_config JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT documents_doctype_check
CHECK (doctype IN ('quote', 'invoice')),
CONSTRAINT documents_status_check
CHECK (status IN ('draft', 'sent', 'accepted', 'expired')),
CONSTRAINT documents_client_doctype_docno_unique
UNIQUE (client_id, doctype, doc_no),
CONSTRAINT documents_client_doctype_year_slug_unique
UNIQUE (client_id, doctype, doc_year, slug)
);
CREATE INDEX idx_documents_provider ON documents (provider_id, status, created_at DESC);
CREATE INDEX idx_documents_client ON documents (client_id, doctype, doc_year);
doc_no scoped to (client_id, doctype) — provider_id is not in the uniqueness key because client_id already implies a provider scope (via clients.provider_id). See client-document-types.contract.md §doc_no sequencing.
document_responses
Records a client's option selection. One row per selection event; the most recent non-null selection is canonical.
CREATE TABLE document_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
option_code TEXT NOT NULL,
payment_method_id UUID NULL REFERENCES payment_methods(id) ON DELETE SET NULL,
contact_value TEXT NULL,
contact_type TEXT NULL,
ip_hash TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_responses_contact_type_check
CHECK (contact_type IS NULL OR contact_type IN ('phone', 'email'))
);
CREATE INDEX idx_document_responses_document ON document_responses (document_id, created_at DESC);
ip_hash is SHA-256 of the request IP, never the raw IP. Used for abuse detection only.
payment_methods
Provider-scoped catalog of accepted payment rails. Referenced by documents.payment_methods_config (visible set) and by document_responses.payment_method_id (client selection).
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
kind TEXT NOT NULL,
label TEXT NOT NULL,
value TEXT NOT NULL,
note TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
sort_order INTEGER NOT NULL DEFAULT 0,
regions TEXT[] NOT NULL DEFAULT ARRAY['GLOBAL'],
market_popularity_by_region JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT payment_methods_kind_check
CHECK (kind IN ('crypto', 'cash', 'prepaid', 'bank_transfer', 'other')),
CONSTRAINT payment_methods_visibility_check
CHECK (visibility IN ('public', 'private', 'archived'))
);
CREATE INDEX idx_payment_methods_provider ON payment_methods (provider_id, visibility, sort_order);
CREATE INDEX idx_payment_methods_regions ON payment_methods USING GIN (regions);
v2 used provider_slug TEXT as the scoping key (payment_methods.provider_slug = 'quinn'). v4 replaces this with the UUID FK provider_id. Slug is a brand surface; UUID is the relational key.
otp_attempts
Short-lived OTP codes. Rate limited per (provider_id, phone_e164) — not globally — so a single human being a client of two providers does not cross limits.
CREATE TABLE otp_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone_e164 TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempt_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_otp_attempts_provider_phone
ON otp_attempts (provider_id, phone_e164, created_at DESC);
v2 keyed on phone_e164 globally (lilith-platform.live/codebase/@features/api/src/entities/otp-attempt/schema.ts). v4 adds provider_id to the key because multi-tenancy makes global phone uniqueness unsound.
provider_brand_domains
Maps a public hostname to a provider_id (and optionally org_id). Queried on every inbound request to platform-api's client-area routes.
CREATE TABLE provider_brand_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
domain TEXT NOT NULL UNIQUE,
path_prefix TEXT NULL,
cookie_domain TEXT NULL,
cookie_path TEXT NOT NULL DEFAULT '/',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_brand_domains_domain ON provider_brand_domains (domain);
path_prefix is non-null only for path-prefix-mode deploys (/clients). cookie_domain is the Domain attribute value for the session cookie; omit for dev (see session cookie semantics below).
Seed entry for Quinn's instance: domain = 'www.cocotte.club', cookie_domain = '.cocotte.club'.
platform-api endpoints (client-area module)
All routes under /client-area/*. The NestJS module is ClientAreaModule.
Auth
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/client-area/auth/otp/request |
None | Look up client by (provider_id, phone_e164). If found, generate 6-digit OTP, hash (SHA-256), store in otp_attempts, enqueue SMS via mac-sync. Rate limit: 3 sends per (provider_id, phone_e164) per 10 min. |
POST |
/client-area/auth/otp/verify |
None | Find active otp_attempts row by (provider_id, phone_e164). Compare code hash. Increment attempt_count; reject if ≥ 5. Mark consumed_at on success. Issue session JWT; set cas_session cookie. |
POST |
/client-area/auth/logout |
Session cookie | Clear cas_session cookie. |
provider_id is resolved from the inbound Host header via provider_brand_domains before any handler runs. It is not a client-supplied parameter.
Documents
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/client-area/documents |
Session cookie | List documents for the authenticated client. Returns safe projection (no body_markdown). Ordered by created_at DESC. |
GET |
/client-area/documents/:year/:slug |
Session cookie | Full document payload. Resolves by (client_id, doctype=any, doc_year=year, slug). |
GET |
/client-area/documents/by-number/:doctype/:docNo |
Session cookie | Resolve document by (client_id, doctype, doc_no). Used by magic-link action=openDocument&doctype=quote&userDocNo=1. |
POST |
/client-area/documents/:year/:slug/respond |
Session cookie | Record a document_responses row. Body: { optionCode, paymentMethodId? }. Returns the updated document + payment method details for the confirmed page. |
GET |
/client-area/documents/:year/:slug/confirmed-state |
Session cookie | Returns the most recent document_responses row for the document + full payment methods payload. Used when the client reloads the confirmed page without SPA state. |
Health
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/client-area/health |
None | Returns { ok: true, macSyncLastSeen: ISO } — the mac-sync heartbeat timestamp from outreach.scheduled_send most recent dispatch, so callers can detect a stalled SMS path before attempting OTP. |
Tenant isolation rules
Every query in ClientAreaModule that touches clients, documents, document_responses, or payment_methods must include provider_id = $resolvedProviderId as a filter. The resolved provider_id comes from a NestJS request-scoped guard (BrandDomainGuard) that runs before any handler and populates req.providerId.
A session cookie from www.cocotte.club can only authenticate clients whose clients.provider_id matches www.cocotte.club's resolved provider_id. Cross-provider document access is impossible at the query layer regardless of cookie validity.
Session cookie semantics
Cookie name: cas_session (Client Area Session — provider-neutral, replaces v2's cocotte_session).
JWT payload:
interface ClientAreaToken {
client_id: string; // UUID from clients.id
provider_id: string; // UUID from users.id — redundant with cookie domain, kept for verification
iat: number;
exp: number;
}
Cookie attributes:
HttpOnly— not readable from JSSameSite=StrictSecure(prod only)Domain— fromprovider_brand_domains.cookie_domain(e.g..cocotte.club); omitted in dev sowww.cocotte.apricot.localworks without DNS wildcardsPath— fromprovider_brand_domains.cookie_path(usually/;/clientsfor path-prefix deploys)
Signing secret: CLIENT_AREA_SESSION_SECRET env var, per-provider if needed, separate from SSO_SESSION_SECRET. Token TTL: 24h default, configurable via CLIENT_AREA_SESSION_TTL_SECONDS.
OTP flow — sequence diagram
Client browser platform-api otp_attempts (pg) mac-sync (plum)
│ │ │ │
│─── POST /auth/otp/request ─────────────────>│ │
│ { phone: '+44...' } │ │
│ │── look up client ────>│ │
│ │ WHERE provider_id=$p │
│ │ AND phone_e164=$phone │
│ │ │ │
│ │── count recent sends ─>│ │
│ │ WHERE provider_id=$p AND phone=$phone │
│ │ AND created_at > now()-10min │
│ │ [reject if ≥ 3] │ │
│ │ │ │
│ │── INSERT otp_attempts ─>│ │
│ │ code_hash=sha256(otp) │
│ │ expires_at=now()+10m │
│ │ │ │
│ │── INSERT outreach.scheduled_send ──────────>│
│ │ service='SMS' │ (on plum's pg) │
│ │ to_handle=phone │ │
│ │ body='Your code is 482910. …magic link…' │
│ │ fire_at=now()+30s │ │
│ │ │ │
│<── 200 { sent: true } ────────────────────── │ │
│ │ │ │ │
│ (30–40s latency) │ │ │── SMS dispatch│
│ │ │ │ via macOS │
│ │ │ │ Messages.app│
│ │ │ │
│─── POST /auth/otp/verify ──────────────────>│ │
│ { phone, code: '482910' } │ │
│ │── find active row ────>│ │
│ │ WHERE provider_id=$p AND phone=$phone │
│ │ AND expires_at > now() │
│ │ AND consumed_at IS NULL │
│ │ │ │
│ │── increment attempt_count ──────────────── >│
│ │ [reject if attempt_count ≥ 5] │
│ │ │ │
│ │── verify sha256(code) == code_hash │
│ │ [reject on mismatch] │
│ │ │ │
│ │── UPDATE consumed_at=now() ───────────────>│
│ │ │ │
│<── 200 Set-Cookie: cas_session=<jwt> ────── │ │
│ │ │ │
│─── GET /client-area/documents/by-number/quote/1 ───────────────> │
│ (magic-link action=openQuote&userDocNo=1) │ │
│ │── SELECT * FROM documents ─────────────── >│
│ │ WHERE client_id=$cid AND doctype='quote' │
│ │ AND doc_no=1 AND provider_id=$p │
│<── 302 /documents/2026/london-first-weekend-together ──────────── │
mac-sync SMS adapter
platform-api inserts into outreach.scheduled_send on plum's mac-sync.db (black:25436 — mirror from plum). mac-sync's scheduled-send-worker polls this table and dispatches via macOS Messages.app.
// codebase/@features/platform-api/src/client-area/sms/mac-sync-sms.service.ts
async sendOtp(toE164: string, code: string, magicLink: string): Promise<void> {
await this.macSyncDb.query(
`INSERT INTO outreach.scheduled_send
(service, to_handle, body, fire_at, created_at)
VALUES ($1, $2, $3, now() + interval '30 seconds', now())`,
['SMS', toE164, `Your code is ${code}. ${magicLink}`],
);
}
Minimum latency: 30–40s (30s scheduled delay + dispatch loop). Acceptable for OTP; the magic-link URL lets clients tap rather than type.
Operational note: mac-sync requires plum (Mac mini) to be awake and connected. GET /client-area/health exposes the mac-sync heartbeat so the provider can detect a stalled path before sending an invite. For high-availability SMS at scale this path needs a Twilio fallback — flagged as a P1 concern, not blocking for P0.
sanitizeDocument() contract
Before any GET /documents/* response, a sanitizeDocument() function strips server-only fields:
- No
password(v2 legacy — not present in v4 schema, no action needed) - No internal
presentation.providerAnnotationsif present payment_methods_config.providerNotesis stripped from the document-level response but included in the confirmed-page response after option selection (the provider notes are deposit-instruction hints, not pre-selection metadata)
Related
client-area.brief.md— overview, multi-tenancy, brand-domain configclient-onboarding.flow.md— full OTP onboarding path with actor/state annotationsclient-document-types.contract.md— doctype extension rules, doc_no sequencingclient-quote-confirm.flow.md— option-select + payment_methods_config details- DESIGN.md §5 —
orgs,org_members, SSO JWT - INFRA.md §3 — DB placement, mac-sync.db mirror