cocottetech/@platform/codebase/@features/client-area/docs/client-area-architecture.md

19 KiB
Raw Blame History

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.


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 JS
  • SameSite=Strict
  • Secure (prod only)
  • Domain — from provider_brand_domains.cookie_domain (e.g. .cocotte.club); omitted in dev so www.cocotte.apricot.local works without DNS wildcards
  • Path — from provider_brand_domains.cookie_path (usually /; /clients for 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 } ──────────────────────  │                    │
     │                      │                       │     │               │
     │ (3040s 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: 3040s (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.providerAnnotations if present
  • payment_methods_config.providerNotes is 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)