Architecture — C4 Model
Four zoom levels of the same system, rendered live from Mermaid sources. C1 shows who uses the platform and the external services it talks to; C2 the runnable containers and data stores; C3 the components inside the zero-dependency Node server — auth, billing, the servicing desk, Pro features, consent, and the privacy/encryption layer; C4 the code-level classes behind config, secrets, billing, analytics, and the demo sandbox. The same sources live in ARCHITECTURE.md for GitHub-native rendering.
C1 System Context
Five kinds of people use the platform — studio clients (booking, deposits, consent signing, reviews), studio staff (real accounts: password or Google/Apple/Meta sign-in), prospects playing in the demo sandbox, platform support agents staffing the phone-in servicing desk, and us as platform admins. Stripe handles deposits and confirms them via signed webhooks; Resend and Twilio deliver email and SMS — including servicing-desk OTP codes — through the store-and-forward queue; Supabase is the planned production database (the RLS-hardening migration is already written).
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")
View Mermaid source
C2 Containers
Everything runs from one zero-dependency Node 24 process serving 28 CSP-strict pages
(no inline JS anywhere) behind a static allowlist — only web directories are ever
servable. Four stores back it: the durable SQLite application DB (bookings with
sealed medical notes, consent archive with sealed signatures, users, subscriptions,
support cases + OTPs, queue, audit), layered JSON config with $secret
refs and the content-key registry, the AES-256-GCM token vault, and one SQLite file
per demo visitor for physical isolation.
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")
View Mermaid source
C3 Components — inside the API server
The router applies security headers, enforces the static allowlist, stamps every
response with an X-Request-Id, and times it into the analytics store.
Each API family delegates to a core module: auth routes to AuthService
and OAuthBroker; signup runs through BillingService
(tiers + coupons), whose tier also gates the Pro APIs; the servicing desk pins
every route to the operator tenant and lets the server decide caller
verification (KYC checklist + OTP codes it generated itself); consent signing
seals signatures through the privacy module; bookings feed the
NotificationQueue whose worker delivers through
Messenger; payments resolve Stripe keys per call through
getSecret(); and every demo request opens its caller's own SQLite
sandbox and closes it in finally.
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")
View Mermaid source
C4 Code — core server classes
Class-level view of the core modules. AuthService,
BillingService, DurableBookingStore,
NotificationQueue, and IdempotencyStore all sit on
AppDatabase (prepared statements, versioned migrations);
OAuthBroker and Messenger resolve credentials through
ConfigManager which falls back to TokenVault; the
privacy module envelope-encrypts sensitive blobs with the vault data
key; typed errors (AuthError, BillingError,
DemoError) map straight to HTTP status codes.
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
View Mermaid source