# ServaloDesk Architecture — C4 Model

The four classic C4 zoom levels for the platform, as Mermaid sources. GitHub
renders these fenced blocks natively; [index.html](index.html) renders the same
sources live in the browser (Mermaid from cdn.jsdelivr.net, already allowed by
the CSP `script-src`).

- **C1 — System Context**: who uses the platform — clients, staff, prospects, support agents, platform admins — and which external systems it talks to.
- **C2 — Containers**: the deployable/runnable pieces — 28 CSP-strict front-end pages, the zero-dependency Node server, and the data stores (durable SQLite app DB with sealed sensitive blobs, config + content registry, vault, demo sandboxes).
- **C3 — Components**: inside the API server — router with static allowlist, the API families (auth/team, bookings, payments, servicing desk, Pro features, consent, admin, demo), and the core modules (`AuthService`, `BillingService`, `OAuthBroker`, `NotificationQueue`, `ConfigManager`, `TokenVault`, privacy/encryption, content registry, `AnalyticsStore`, `DemoSandboxManager`).
- **C4 — Code**: class-level view of those core modules and how they relate.

---

## C1 — System Context

```mermaid
C4Context
    title C1 - System Context: ServaloDesk Studio Management Platform

    Person(customer, "Studio Client", "Books appointments, pays deposits, signs digital consent forms, joins waitlists, leaves reviews")
    Person(staff, "Studio Staff", "Owner / manager / artist / front desk: bookings, team, consent archive, Pro workspace")
    Person(prospect, "Prospect", "Tries the full product in an isolated demo sandbox before signing up")
    Person(agent, "Support Agent", "Platform servicing desk: phone-in cases, KYC + OTP caller verification, credential resets, account closures")
    Person(padmin, "Platform Admin", "Operates the platform: P&L, per-product and per-client analytics, queue, sandboxes, audit")

    System(servalodesk, "ServaloDesk Platform", "White-label booking, client and studio management SaaS. Zero-dependency Node.js, durable SQLite with envelope encryption, CSP-strict HTML front-end")

    System_Ext(idps, "Google / Apple / Meta", "Social sign-in (OAuth2 / OIDC) - Google live, Apple parked, Meta pending keys")
    System_Ext(stripe, "Stripe", "Deposit PaymentIntents + signed event webhooks")
    System_Ext(resend, "Resend", "Transactional email: booking flow, invites, reset links, OTP codes")
    System_Ext(twilio, "Twilio", "SMS reminders + OTP codes (A2P 10DLC registration pending)")
    System_Ext(webpush, "Web Push", "Browser notifications")
    System_Ext(instagram, "Instagram Graph API", "Portfolio and social content")
    System_Ext(supabase, "Supabase (planned)", "Production Postgres with RLS + storage")

    Rel(customer, servalodesk, "Books via /book/{slug}, pays, signs consent, submits reviews", "HTTPS")
    Rel(staff, servalodesk, "Signs in (password or social), runs the studio, manages the team", "HTTPS + session cookie")
    Rel(prospect, servalodesk, "Plays with seeded demo portals, leaves a lead", "HTTPS + X-Sandbox-Id")
    Rel(agent, servalodesk, "Masked lookups, cases, server-decided KYC, gated account actions", "HTTPS + session cookie")
    Rel(padmin, servalodesk, "Monitors P&L, product/client analytics, queue, audit trail", "HTTPS")

    Rel(servalodesk, idps, "Authorization-code + PKCE; demo mode without keys", "OAuth2")
    Rel(servalodesk, stripe, "Creates PaymentIntents (idempotency keys forwarded); verifies webhook signatures", "REST")
    Rel(servalodesk, resend, "Emails via store-and-forward queue", "REST")
    Rel(servalodesk, twilio, "SMS via queue, timezone-aware quiet hours 21-08", "REST")
    Rel(servalodesk, webpush, "Pushes notifications", "Web Push")
    Rel(servalodesk, instagram, "Pulls portfolio media", "REST")
    Rel(servalodesk, supabase, "Planned persistence (RLS-hardened schema)", "Postgres")

    UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")
```

## C2 — Containers

```mermaid
C4Container
    title C2 - Containers: inside the ServaloDesk Platform

    Person(visitor, "Client / Staff / Prospect / Agent / Admin", "Any browser user")

    System_Boundary(servalodesk, "ServaloDesk Platform") {
        Container(pages, "Web Front-End", "HTML + CSS + vanilla JS", "28 CSP-strict pages: marketing landing + blog, booking widget + /book/{slug}, portals, payments, sign-in (social + invites), signup, team, Pro workspace, consent (staff + public sign), reviews, demo portals, admin hub (P&L + analytics), servicing desk. No inline JS anywhere")
        Container(sharedjs, "Shared Browser Modules", "shared/analytics.js + branding.js + content.js", "Fetch wrapper, page/component timings, sendBeacon batching; white-label branding on data-brand slots; content registry filling data-content slots")
        Container(server, "API and Static Server", "Node.js 24, zero npm dependencies", "Strict security headers, static-dir allowlist, routes /api/*, X-Request-Id correlation, structured JSON logs, queue worker every 30s")
        ContainerDb(appdb, "Application DB", "SQLite (data/app.db), WAL, chmod 600", "Durable: bookings, sealed medical notes + consent signatures, consent templates, users, sessions, invites, password resets, subscriptions + coupons, waitlist, flash sheets, reviews, support cases + OTPs, queue, leads, idempotency keys, audit log")
        ContainerDb(configstore, "Config Store", "JSON files", "config/default.json + config/tenants/*.json - secrets only as $secret refs, never literals; content-key registry; platform.products")
        ContainerDb(vaultdb, "Token Vault", "AES-256-GCM encrypted file", "secrets/vault.json - service credentials + data-encryption key, per-entry age metadata, audit trail, rotation")
        ContainerDb(sandboxes, "Demo Sandboxes", "SQLite via node:sqlite", "One DB file per visitor under data/demo-sandboxes/ - physical isolation, 2h TTL, 200 cap")
    }

    System_Ext(idps, "Google / Apple / Meta", "OAuth2 / OIDC")
    System_Ext(stripe, "Stripe", "Payments + signed webhooks")
    System_Ext(msgproviders, "Resend / Twilio", "Email + SMS delivery (incl. OTP codes)")
    System_Ext(supabase, "Supabase (planned)", "Production Postgres + RLS")

    Rel(visitor, pages, "Uses", "HTTPS")
    Rel(pages, sharedjs, "Loads on every page, analytics first")
    Rel(sharedjs, server, "Beacons timings, fetches public config", "sendBeacon / fetch")
    Rel(pages, server, "JSON API - HttpOnly session cookie; demo calls carry X-Sandbox-Id", "fetch")
    Rel(server, appdb, "Prepared statements only; migrations 001-013 on boot; sensitive blobs sealed before write")
    Rel(server, configstore, "Layered read: default then tenant then env")
    Rel(server, vaultdb, "getSecret(): env first, then vault decrypt")
    Rel(server, sandboxes, "Prepared statements only")
    Rel(server, idps, "Authorization-code + PKCE, single-use state", "OAuth2")
    Rel(server, stripe, "PaymentIntents out; HMAC-verified webhooks in", "REST")
    Rel(server, msgproviders, "Queue worker delivers with retry + circuit breaker", "REST")
    Rel(server, supabase, "Planned: 001-security-hardening.sql applied first", "Postgres")

    UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")
```

## C3 — Components

```mermaid
C4Component
    title C3 - Components: API and Static Server (src/server.js)

    Container(browser, "Web Front-End", "Browser", "Pages + shared modules")

    Container_Boundary(server, "API and Static Server") {
        Component(router, "Router and Static Handler", "node:http", "Strict CSP headers; static-dir allowlist (only web dirs servable, dotfiles 404); X-Request-Id on every response; structured JSON logs with PII redaction")
        Component(authapi, "Auth and Team API", "/api/auth/*, /api/team/*", "Register, login, OAuth callbacks, password reset completion, invites, roles, seats, IP activity, audit; leads + privacy requests")
        Component(bookingapi, "Booking API", "/api/bookings", "Public create per provisioned tenant; staff reads tenant-scoped; Idempotency-Key replay; feeds the notification queue")
        Component(payapi, "Payments API", "/api/payments/*", "Stripe PaymentIntents; HMAC-verified webhook confirms deposits; keys resolved per call; idempotency keys forwarded")
        Component(supportapi, "Servicing Desk API", "/api/support/*", "Masked lookup, phone-in cases, append-only history; OTP send/verify to contacts ON FILE; server-decided KYC (3 of 6 checks); KYC-gated credential reset + account closure; operator-tenant pinned")
        Component(proapi, "Pro Feature APIs", "/api/waitlist, /api/flash, /api/reviews, /api/analytics/clients", "Tier-gated with 403 upsell; public per-tenant signup/gallery/walls; demo-sandbox overlay via X-Sandbox-Id")
        Component(consentapi, "Consent API", "/api/consent/*", "Versioned templates per tenant; rate-limited public sign; immutable archive with frozen template snapshots; no mutation route for signatures")
        Component(adminapi, "Admin API", "/api/admin/*", "Platform-admin only: P&L cost report + CSV, per-product MRR/tier mix, per-client drilldown")
        Component(demoapi, "Demo API", "/api/demo/*", "Sandbox lifecycle + portal CRUD; per-IP create limiter; DemoError to HTTP map")
        Component(authsvc, "AuthService", "src/auth.js", "scrypt passwords, hashed session/invite/reset tokens, role permission map (owner/manager/artist/front_desk + platform-only support), tier seat limits 3/10/25, IP tracking")
        Component(billingsvc, "BillingService", "src/billing.js", "Tiers $29/$49/$79; LAUNCH15 coupons with atomic SQL redemption; tier resolver feeds seat limits and Pro gating")
        Component(oauthbroker, "OAuthBroker", "src/oauth.js", "Google/Apple/Meta authorization-code + PKCE; single-use state; demo mode without keys")
        Component(queue, "NotificationQueue", "src/queue.js", "Store-and-forward: atomic claim leases, backoff retries 1/5/15/60m, dead-letter at 5, timezone-aware SMS quiet hours 21-08")
        Component(messenger, "Messenger", "src/messaging.js", "Template render (incl. support OTP); Resend + Twilio adapters behind retry + circuit breakers; demo mode")
        Component(configmgr, "ConfigManager", "src/config.js", "deepMerge default-tenant-env; publicConfig() whitelist; getSecret() env then vault")
        Component(vault, "TokenVault", "src/vault.js", "AES-256-GCM at rest, scrypt passphrase, per-entry age metadata, audit trail, rotate(); 90-day staleness check enforced weekly in CI")
        Component(privacy, "Privacy Module", "src/privacy.js", "Envelope encryption seal()/unseal() with the vault data key; PAN (Luhn) screen; IP pseudonymization for audit rows")
        Component(content, "Content Registry", "src/content.js + shared/content.js", "Copy keys in config/default.json; server strings via contentFor(); browser fills data-content slots")
        Component(store, "AnalyticsStore", "src/analytics.js", "Ring buffers, p50/p95/p99, SLA compliance vs config targets")
        Component(demomgr, "DemoSandboxManager", "src/demo-db.js", "create/open/reset/destroy/sweep; TTL from file birthtime; 200 cap")
        Component(appdbmod, "AppDatabase", "src/db.js", "Versioned migrations 001-013; DurableBookingStore; IdempotencyStore; append-only audit log with pseudonymized IPs")
    }

    ContainerDb(appdbfile, "data/app.db", "SQLite WAL, chmod 600")
    ContainerDb(configstore, "Config Store", "JSON files")
    ContainerDb(vaultfile, "secrets/vault.json", "Encrypted file")
    ContainerDb(sandboxfiles, "data/demo-sandboxes/*.db", "SQLite, one per visitor")
    System_Ext(idps, "Google / Apple / Meta", "OAuth2")
    System_Ext(stripe, "Stripe", "Payments")
    System_Ext(msgproviders, "Resend / Twilio", "Email + SMS")

    Rel(browser, router, "HTTP requests")
    Rel(router, authapi, "routes")
    Rel(router, bookingapi, "routes")
    Rel(router, payapi, "routes")
    Rel(router, supportapi, "routes")
    Rel(router, proapi, "routes")
    Rel(router, consentapi, "routes")
    Rel(router, adminapi, "routes")
    Rel(router, demoapi, "routes")
    Rel(router, store, "recordServer() every response")
    Rel(authapi, authsvc, "sessions, invites, roles, seats, resets")
    Rel(authapi, oauthbroker, "start() / callback()")
    Rel(authapi, billingsvc, "signup(): tenant + subscription + owner")
    Rel(oauthbroker, authsvc, "loginWithIdentity()")
    Rel(oauthbroker, idps, "token exchange", "REST")
    Rel(bookingapi, queue, "scheduleBookingFlow() incl. consent link")
    Rel(supportapi, authsvc, "createPasswordReset(), disableUserBySupport()")
    Rel(supportapi, queue, "OTP codes + reset links to contacts on file")
    Rel(proapi, billingsvc, "tier gate (pro)")
    Rel(consentapi, privacy, "seal() signatures + initials")
    Rel(queue, messenger, "deliver() due items")
    Rel(messenger, msgproviders, "send with retry + breaker", "REST")
    Rel(authsvc, billingsvc, "tier resolver: subscription tier wins")
    Rel(authsvc, appdbmod, "prepared statements")
    Rel(bookingapi, appdbmod, "DurableBookingStore (medical notes sealed)")
    Rel(supportapi, appdbmod, "support_cases / support_otps")
    Rel(proapi, appdbmod, "waitlist / flash_sheets / reviews")
    Rel(consentapi, appdbmod, "consent_templates / consent_signatures")
    Rel(adminapi, appdbmod, "cross-tenant rollups")
    Rel(billingsvc, appdbmod, "subscriptions / coupons")
    Rel(queue, appdbmod, "notification_queue table")
    Rel(payapi, configmgr, "getSecret(services.payments.*)")
    Rel(configmgr, vault, "vault.get(name) when env unset")
    Rel(configmgr, configstore, "reads")
    Rel(content, configstore, "content section")
    Rel(privacy, vault, "data key: security.dataEncryptionKey at boot")
    Rel(vault, vaultfile, "AES-256-GCM read/write")
    Rel(appdbmod, appdbfile, "WAL, migrations on boot")
    Rel(demoapi, demomgr, "open(id) per request, closed in finally")
    Rel(demomgr, sandboxfiles, "one DB file per sandbox")
    Rel(payapi, stripe, "PaymentIntents", "REST")

    UpdateLayoutConfig($c4ShapeInRow="4", $c4BoundaryInRow="1")
```

## C4 — Code

```mermaid
classDiagram
    direction LR

    class ConfigManager {
        +tenants() string[]
        +forTenant(tenant) object
        +publicConfig(tenant) object
        +serviceRegistry(tenant) object
        +getSecret(dottedPath, opts) string
    }
    class TokenVault {
        -masterKey Buffer
        +unlocked() boolean
        +set(name, value, actor)
        +get(name, actor) string
        +list() names + updatedAt age metadata
        +rotate(actor) fresh salt re-encrypt
        +staleEntries(entries, maxDays 90) overdue list
    }
    class VaultError {
        +code string
    }
    class AnalyticsStore {
        +recordServer(method, path, ms, status)
        +ingest(events, maxBatch)
        +summary() slaCompliance + breaches
    }
    class Series {
        -samples ring buffer (1000)
        +add(ms, isError)
        +stats(slaMs) p50 p95 p99
    }
    class DemoSandboxManager {
        -dir, ttlMs 2h, max 200
        +create() id + expiresAt
        +open(id) Sandbox
        +info(id) counts + expiry
        +reset(id)
        +destroy(id)
        +sweep()
        +list() stats()
    }
    class Sandbox {
        +counts() artists() customers()
        +addCustomer(fields)
        +toggleVip(customerId)
        +appointments() appointmentsForEmail(email)
        +setAppointmentStatus(id, status, actor)
        +payDeposit(id)
        +requests() addRequest(fields)
        +decideRequest(id, decision, opts)
        +activity(limit)
        +close()
    }
    class DemoError {
        +code NOT_FOUND EXPIRED LIMIT VALIDATION BAD_STATE
    }
    class AppDatabase {
        +prepare(sql) Statement
        +audit(entry)
        +auditTrail(opts) entries
        +close()
    }
    class DurableBookingStore {
        +create(request) booking
        +get(id) list()
        +confirmDeposit(id) cancel(id)
        +readMedicalNotes(id, opts)
        +purgeMedicalNotes(id, opts)
        +exportSubject(email)
        +eraseSubject(email, opts)
    }
    class IdempotencyStore {
        +hashBody(raw) string
        +get(key) stored
        +save(key, hash, status, response)
    }
    class AuthService {
        +registerOwner(fields) user
        +login(credentials) token
        +loginWithIdentity(profile) token
        +authenticate(token, ip) user
        +createInvite(actor, fields) acceptInvite(fields)
        +changeRole(actor, userId, role)
        +deactivate(actor, userId)
        +createPasswordReset(userId) single-use 60min token
        +resetPassword(token, password)
        +disableUserBySupport(userId)
        +seatUsage(tenant, location) tier limits 3 10 25
        +loginActivity(actor) ip events
    }
    class AuthError {
        +code VALIDATION UNAUTHORIZED FORBIDDEN SEAT_LIMIT EXPIRED
    }
    class BillingService {
        +plans() launch-coupon availability
        +signup(fields) tenant + subscription + owner
        +subscription(tenant)
        +tierFor(tenant) feeds seats + Pro gate
        +coupons() burn-down
    }
    class BillingError {
        +code VALIDATION UNKNOWN_COUPON WRONG_TIER SOLD_OUT EXPIRED CONFLICT
    }
    class privacy {
        +seal(value) unseal(value) enc1 envelope
        +encryptSensitive() decryptSensitive() AES-256-GCM
        +setDataEncryptionKey(secret) vault key at boot
        +pseudonymizeIp(ip) salted ip hash
        +containsCardNumber(text) Luhn PAN screen
    }
    class OAuthBroker {
        +providers() mode live or demo
        +start(provider, origin) redirect
        +callback(provider, params) session
    }
    class NotificationQueue {
        +enqueue(message) dedupeKey
        +scheduleBookingFlow(booking)
        +processDue() outcomes
        +requeue(id) dead-letter recovery
        +stats() list()
    }
    class Messenger {
        +deliver(template, recipient) mode
        +breakerStates()
    }
    class CircuitBreaker {
        +state closed open half-open
        +exec(fn)
    }
    class Logger {
        +child(bindings) reqId stamping
        +info() warn() error() PII-redacted JSON lines
    }

    ConfigManager --> TokenVault : getSecret() falls back to
    TokenVault ..> VaultError : throws
    AnalyticsStore "1" *-- "≤200" Series : per route key
    DemoSandboxManager --> Sandbox : opens, 1 SQLite file each
    Sandbox ..> DemoError : throws, mapped to HTTP
    DemoSandboxManager ..> DemoError : throws
    DurableBookingStore --> AppDatabase : prepared statements
    DurableBookingStore --> privacy : medical notes sealed at rest
    IdempotencyStore --> AppDatabase : replay table
    AuthService --> AppDatabase : users sessions invites resets
    AuthService ..> AuthError : throws, mapped to HTTP
    AuthService --> BillingService : tierResolver, subscription tier wins
    BillingService --> AppDatabase : subscriptions coupons
    BillingService ..> BillingError : throws, mapped to HTTP
    OAuthBroker --> AuthService : loginWithIdentity()
    OAuthBroker --> ConfigManager : client secrets via getSecret()
    NotificationQueue --> AppDatabase : notification_queue
    NotificationQueue --> Messenger : deliver() due items
    Messenger "1" *-- "2" CircuitBreaker : resend + twilio
    Messenger --> ConfigManager : provider keys via getSecret()
    NotificationQueue --> Logger : dead-letter alerts
```
