SERVICE_OVERVIEW — theme-config-service
Bundle index: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL · SYNC_CONTRACT · AI_INTEGRATION · SECURITY_MODEL · OBSERVABILITY · TESTING_STRATEGY · DEPLOYMENT_TOPOLOGY · FAILURE_MODES · LOCAL_DEV_SETUP · SERVICE_READINESS · SERVICE_RISK_REGISTER · MIGRATION_PLAN
Strategic anchors: 02 Enterprise Architecture · 04 Event-Driven Architecture · 05 API Design · 06 Data Models · 07 Security/Compliance/Tenancy · 08 AI Architecture · ADR-0002 Multi-tenancy · ADR-0003 Electron Offline-First
1. Purpose
theme-config-service is the single source of truth for what each tenant looks and feels like on every consumer surface of Ghasi Melmastoon — the multi-tenant hotel SaaS whose backoffice is an Electron offline-first desktop and whose cloud is GCP. A single shared codebase serves the consumer meta layer, the tenant-branded booking site (web + mobile), and the meta-search hotel-detail card; this service is the runtime mechanism that makes that single codebase look uniquely each tenant's via versioned design tokens, layout presets, content blocks, navigation menus, per-locale copy, booking-flow toggles, and email theme overrides.
The service exists for five reasons that no other service can satisfy:
- One token vocabulary. Every consumer surface and every email theme refers to the same semantic tokens (
color.primary,typography.body.size.md,spacing.4, …). Owning that vocabulary in one place — and the resolution rules that turn semantic tokens into platform-specific CSS, mobile theme objects, and email-safe inline styles — is what lets us evolve the design system without coordinating N services. - One publish lifecycle. A tenant must be able to draft a brand refresh, preview it on a shared link, sign off, publish atomically, and roll back if something looks wrong — without taking down the booking flow. We own the
draft → preview_ready → published → archivedstate machine and the atomic publication pointer. - One CDN cache contract. The published theme bundle is served at edge from Cloud CDN; only this service knows when to invalidate which tag and which
versionto bump on the bundle URL. - One i18n + RTL boundary. Pashto, Dari, Arabic, Urdu need RTL; tokens must derive logical-property variants; missing translations need a fallback chain. Centralising this prevents every consumer surface from re-implementing the same fallback logic incorrectly.
- One audit trail for brand changes. "Who published this? When? With what tokens? Did publishing degrade contrast?" — questions the platform must answer for security review, GDPR, and tenant disputes — are anchored in our publication ledger.
2. Bounded context
Context name: Theming & Configuration
Domain class: Supporting (a force multiplier on every consumer surface; differentiator is not in shipping a token system but in the per-tenant lifecycle, RTL safety, and zero-deploy customisation)
Ubiquitous language: Theme, ThemeVersion, ThemePublication, DesignTokenSet, SemanticColor, TypographyPair, SpacingScale, RadiusScale, ShadowScale, MotionToken, LayoutPreset, ContentBlock, NavigationConfig, MenuItem, BookingFlowConfig, BookingFlowStep, FieldRequirement, EmailTheme, LocalePack, FallbackChain, PreviewToken, AssetRef (URL into file-storage-service), AIProvenance (reference only — owned by ai-orchestrator-service).
What is in:
- The
Themeaggregate, its versioned history, and theThemePublicationpointer that names the active version. - The semantic token vocabulary and resolution into deliverable artefacts (CSS variables map, JSON token bundle, MJML-friendly inline-style map for email).
- The
LayoutPresetregistry (platform-global), and the per-ThemeVersionselection of presets per page (home / listing / detail / booking / post-stay). ContentBlockCRUD with per-locale rich-text bodies, allow-listed Markdown / sanitised HTML.NavigationConfigfor header / footer / mobile-drawer.LocalePack(per-tenant enabled locales, default locale, fallback chain) and per-locale formatting hints (dateFormatPattern,currencyDisplay,phoneFormat).BookingFlowConfig(which steps + which optional fields).EmailTheme(override block consumed bynotification-servicefor transactional email rendering).PreviewTokenminting and verification.- The publish workflow, atomic flip, rollback, OCC, CDN invalidation, audit projection.
- Synchronous read APIs for the BFFs.
What is out:
- Raw assets (logos, hero images, videos, fonts) — owned by
file-storage-service. We reference them by URL. - Real translation of copy —
ai-orchestrator-serviceproduces drafts; we persist the approved strings. - Tenant identity / domain provisioning —
tenant-serviceowns the per-tenant verified hosts; we project the values needed for routing. - OAuth / authentication of consumers — anonymous public reads of the published bundle are the norm; staff edits go through
iam-service-issued JWTs. - CDN itself — Cloud CDN is platform infra; we hold the adapter that issues invalidation requests.
- Email rendering —
notification-servicerenders the email; we just hand it the email-theme block.
3. Aggregates owned
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
Theme | root, 1 per (tenantId, propertyId?) | Logical theme; carries the active publication pointer + version history | thm_ |
ThemeVersion | child of Theme, 1..N | Immutable snapshot of tokens + layout selections + content + navigation + booking-flow + email overrides; states draft → preview_ready → published → archived | thv_ |
ThemePublication | child of Theme, exactly 1 active per Theme | The version currently served to consumers; rollback creates a new publication pointing at an older version | thp_ |
LayoutPreset | platform-global (no tenantId) | Canonical layout preset registered by Frontend Platform; tenants select one per page | lpr_ |
ContentBlock | child of ThemeVersion, 1..N | Page-attached block (about/policy/faq/etc.) with per-locale body | cnb_ |
NavigationConfig | child of ThemeVersion, 1 per surface | Ordered tree of menu items with per-locale labels | nvc_ |
BookingFlowConfig | child of ThemeVersion, 1 | Toggles + ordered step list for the booking flow | bfc_ |
EmailTheme | child of ThemeVersion, 1 | Override block consumed by notification-service | emt_ |
LocalePack | child of ThemeVersion, 1..N (one per locale) | Keyed copy bundle (cta.book_now, errors.required_field, …) | (composite, no prefix) |
PreviewToken | root, short-lived (TTL 24h, configurable) | Signed token granting anonymous read of one unpublished ThemeVersion on the preview host | pvt_ |
Theme.tenantId is mandatory; Theme.propertyId is null for tenant-default themes (Phase 0) and non-null for per-property overrides (Phase 2 chain branding). Resolution is "property-override wins; otherwise fall back to tenant-default; otherwise fall back to platform scaffold".
4. Responsibilities (numbered)
- Default-theme provisioning. On
melmastoon.tenant.created.v1, scaffold aThemewith one publishedThemeVersionderived from themelmastoon-defaultplatform scaffold, in the tenant's primary locale. The booking flow must be live the instant the tenant exists. - Draft authoring. Create new
ThemeVersions by cloning the active version (default) or a blank scaffold. Apply patches to tokens, layout selections, content blocks, navigation, booking-flow, email theme, and locale packs. OCC enforced viaIf-Match: <version>. - Token validation. On every save (and again at publish), validate (a) token schema (no missing semantic colors), (b) WCAG AA contrast for every foreground/background pair the design system uses, (c) absence of physical layout properties in custom CSS variables. Block save on validation failure; emit
theme.token_validation_failed.v1(audit only, not consumer-facing). - Preview minting. Mint a
PreviewTokenfor any non-archivedThemeVersion; build the bundle once and cache; share ahttps://preview.melmastoon.app/<previewToken>URL the tenant can send to stakeholders. Tokens expire and rotate; revocation is immediate. - Atomic publish. On
publish, run final validation, build the deliverable bundle (JSON tokens + CSS variables map + email-theme JSON), upload to GCS atgs://melmastoon-themes/<tenantId>/<themeId>/<version>/bundle.json(immutable), atomically flip theThemePublicationpointer in a single Postgres transaction with an outbox event, swap the Memorystore key, and queue a Cloud CDN invalidation. Emittheme.published.v1,theme.tokens_changed.v1(with token diff),theme.cdn_cache_invalidated.v1. - Rollback. Publish a previous version (no destructive history). Same atomic flip; emits
theme.rolled_back.v1carrying both the new and previous version ids. - Per-locale management. Add / remove locales from the enabled set with safeguards (cannot remove
defaultLocale; cannot remove a locale referenced by an activePreviewToken); emittheme.locale_added.v1/theme.locale_removed.v1. - Fallback resolution. When the published bundle is requested with
?locale=ps-AFand the requested key is not translated, walk the fallback chain (ps-AF → fa-AF → ar-SA → en) declared on theLocalePack. If the chain ends without a match, return thedefaultvalue baked into the platform scaffold and emit a metric (theme.locale_fallback_missed). - RTL token derivation. Resolve
direction = autoper locale:rtlforar,ps,fa,ur,he;ltrotherwise. Derive logical-property tokens (spacing.start,spacing.end) from physical scales and emit them in the bundle. Reject any custom CSS variable key that uses physicalleft/rightoutside an explicitrtl-mirror: falseopt-out. - Asset reference integrity. Persist asset references as
MediaRefURLs intofile-storage-service. On consumedmelmastoon.media.deleted.v1, scan published versions referencing the URL; mark asbroken, emittheme.broken_asset_detected.v1, alert the tenant admin in backoffice. - Email theme co-resolution. Expose a per-tenant read endpoint that
notification-servicereads at template-render time: returns the active email theme block (logo URL, header gradient, primary color, footer markup, social-link nav). Cached in Memorystore with the same invalidation tag as the booking bundle. - Booking-flow configuration. Expose
bookingFlowblock (which steps / which optional fields per step) consumed bybff-tenant-booking-service; toggles likecaptureGuestPassport,requestAirportTransport,allowGiftBooking,requireGuestSignature,showCancellationPolicySummary. Validate against the canonical step + field registry on save. - Concurrent edit conflict (OCC). Every mutating endpoint requires
If-Match: <version>. Mismatch →412 Precondition Failedwith the conflicting fields surfaced inerrors[]so the editor UI can three-way-merge. - CDN cache invalidation. Issue Cloud CDN purge by tag
theme:<themeId>; tag is set on every served bundle response so the purge invalidates only the affected tenant. Emittheme.cdn_cache_invalidated.v1with the bundle URL andCache-Tagvalue. - Audit projection. Every publish, rollback, locale change, token change, and broken-asset detection is appended to the audit projection consumed by
audit-service.
5. Upstream / downstream context map
┌─────────────────────────────────┐
│ tenant-service │ per-tenant settings,
│ │ default locale,
│ │ currency display,
│ │ enabled domains
└────────────────┬────────────────┘
│ tenant.created.v1
│ tenant.config_updated.v1
│ tenant.deleted.v1
▼
┌──────────────────┐ ┌──────────────────────────┐
│ file-storage-svc │ media.uploaded.v1 / media.deleted.v1 │ ai-orchestrator-service │
│ │ ─ asset URL refs ─ │ ai_drafted_content_ready │
└────────┬─────────┘ │ (palette / translations) │
│ └─────────────┬────────────┘
│ │
┌────────▼─────────────────────────────────────────────────────────────────▼────────────┐
│ theme-config-service │
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Authoring API │→ │ OCC + token │→ │ Validate + │→ │ Build │ → GCS bundle │
│ │ (drafts) │ │ guard │ │ contrast │ │ bundle │ (immutable) │
│ │ │ │ │ │ (WCAG AA) │ │ (json+css) │ │
│ └────────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ Preview-token │ │ CDN invalidator │ │
│ │ minter │ │ (Cloud CDN tag) │ │
│ └─────────────────┘ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────────┼─────────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌─────────────────────────────┐ ┌────────────────────────┐
│ bff-consumer-service │ │ bff-tenant-booking-service │ │ notification-service │
│ (meta detail page) │ │ (full booking funnel) │ │ (email theme reads) │
└──────────────────────┘ └─────────────────────────────┘ └────────────────────────┘
6. Theme publishing lifecycle — ASCII state diagram
┌──────────────────────────────┐
│ │
│ (no version exists) │
│ │
└──────────────┬───────────────┘
│ POST /api/v1/themes/:id/versions
│ { source: 'clone_active' | 'blank' }
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────┐ │
│ │ draft │ ◀──── PATCH /api/v1/theme-versions/:id (OCC, repeatable) │
│ └────┬─────┘ │
│ │ │
│ │ POST /api/v1/theme-versions/:id/preview │
│ ▼ │
│ ┌──────────────────┐ preview token issued (TTL 24h) │
│ │ preview_ready │ ──▶ shareable URL: preview.melmastoon.app/<token> │
│ └────┬─────────────┘ │
│ │ │
│ │ POST /api/v1/theme-versions/:id/publish │
│ │ 1. final validation (tokens + contrast + RTL parity + assets) │
│ │ 2. build bundle (json + css vars + email block) │
│ │ 3. upload to GCS (immutable, content-addressed) │
│ │ 4. atomic flip ThemePublication → this version (Postgres tx) │
│ │ 5. swap Memorystore key (theme:<themeId>:published) │
│ │ 6. emit theme.published.v1 + theme.tokens_changed.v1 │
│ │ 7. enqueue CDN invalidation (Cache-Tag theme:<themeId>) │
│ ▼ │
│ ┌──────────────────┐ │
│ │ published │ ◀── (the *previously* published version goes here) │
│ │ (active for tnt) │ once a *new* version supersedes it │
│ └────┬─────────────┘ │
│ │ │
│ │ POST /api/v1/themes/:id/rollback { toVersion: thv_… } │
│ │ → publishes a prior version; this current published version │
│ │ transitions to `archived` with `archivedReason='rolled_back'`│
│ ▼ │
│ ┌──────────────────┐ │
│ │ archived │ — no consumer reads this version directly anymore │
│ │ (immutable) │ except via /api/v1/theme-versions/:id (audit) │
│ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Compensation paths:
- Validation fails on publish → version stays in preview_ready; emit
theme.publish_rejected.v1 with the failed checks; never half-publish.
- GCS upload succeeds but Postgres flip fails → bundle remains in GCS
(orphan, swept by retention); no events emitted; no consumer impact.
- Postgres flip succeeds but Memorystore swap fails → next read repopulates
the cache from GCS; emit cache_swap_failed metric (warn).
- CDN invalidation fails → up to 60s of stale edge bundles is acceptable;
retry queue with exponential backoff; alert if failures persist > 5 min.
7. Key invariants enforced in the domain layer
- No cross-tenant references. Every aggregate carries a
TenantIdvalue object; the constructor refuses missing or mismatched values.LayoutPresetis the only platform-global aggregate (tenantId = null) and is constructed via a separatePlatformLayoutPreset.create()factory. (MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE) - Exactly one published version per Theme. The
ThemePublicationtable has a unique index on(themeId)wherestatus = 'active'. Atomic flips run inside a single Postgres transaction. (MELMASTOON.THEME.PUBLISH_CONFLICT) - A
ThemeVersionis immutable once published. Any patch attempt against apublishedorarchivedversion returns409 MELMASTOON.THEME.VERSION_IMMUTABLE. To change anything, create a new draft. enabledLocalesmust includedefaultLocale. Removing the default locale is rejected with422 MELMASTOON.THEME.DEFAULT_LOCALE_REQUIRED.- Fallback chain must terminate at
defaultLocaleoren. Cycles or open-ended chains are rejected at draft save. (MELMASTOON.THEME.FALLBACK_CHAIN_INVALID) - Layout-preset selection must reference a registered preset. Unknown preset id →
MELMASTOON.THEME.LAYOUT_PRESET_UNKNOWN. - Token contrast invariant at publish. Every (foreground, background) semantic pair must meet WCAG AA contrast (4.5:1 for body, 3:1 for large text). Failures block publish with
MELMASTOON.THEME.TOKEN_INVALIDand a list of violating pairs. - RTL parity invariant. The tokens bundle resolves direction
autoper locale; the resolved bundle must contain logical-property tokens for every spacing/padding key. Missing logical variants →MELMASTOON.THEME.RTL_VARIANT_MISSINGat publish. - Asset references must resolve. At publish, every
MediaRefURL is HEAD-checked againstfile-storage-service; broken refs reject withMELMASTOON.THEME.ASSET_BROKEN(override allowed by?allowBrokenAssets=truefor staged migrations only). - Asset size budget. Logo ≤ 1 MiB; hero ≤ 8 MiB; theme video ≤ 32 MiB. Enforced via the file-storage-service contract, but mirrored here at draft save. (
MELMASTOON.THEME.ASSET_TOO_LARGE) - OCC version checked on every save. (
MELMASTOON.GENERAL.PRECONDITION_FAILED) - PreviewToken is single-version, single-use-per-pair, expiring. Default TTL 24h; max TTL 7 days; revocation is immediate (cache-key purge).
- AI-drafted palette / translations require HITL before applying. A draft mutation that uses AI-generated tokens requires
aiProvenance+ anapprovedByactor before save. (MELMASTOON.AI.HITL_REQUIRED) - Booking-flow steps reference the canonical registry. Unknown step id →
MELMASTOON.THEME.BOOKING_STEP_UNKNOWN.
8. Hot read paths
| Read | Frequency | Caching strategy |
|---|---|---|
Active published bundle for (tenantId, propertyId?) | per consumer page view (very high) | Cloud CDN (TTL 5 min) → Memorystore key theme:<themeId>:published (TTL 1h, invalidated on publish) → GCS bundle object |
Active published bundle for (host, path) (BFF resolution) | per BFF request | Memorystore key theme:by-host:<host> (TTL 5 min) |
Email theme block for (tenantId, propertyId?) | per email render in notification-service | Memorystore key theme:<themeId>:email (TTL 10 min, invalidated on publish) |
| Layout-preset registry | per BFF cold start | Memorystore key theme:layout-presets (TTL 1h; rare updates) |
| Draft bundle for the authoring UI | per editor keystroke (debounced) | No cache; direct Postgres read with tenant_id RLS context |
Preview bundle for previewToken | per preview-link visit | Memorystore key theme:preview:<token> (TTL = token TTL) |
Locale fallback table for (tenantId) | per render | Memorystore key theme:<themeId>:locales (TTL 10 min) |
9. Cost & scale envelope
| Dimension | Target |
|---|---|
| Tenants per region (Phase 0) | 200 |
| Themes per tenant | 1 (Phase 0); up to 1 + N properties (Phase 2 chains) |
| Versions per theme retained on hot tier | last 50; older archived to GCS coldline |
| Published-bundle p99 size | ≤ 40 KB gzipped (excluding asset bytes) |
| Published-bundle reads per second (steady-state) | < 5 / s — CDN absorbs the rest |
| Authoring API p99 | < 250 ms |
| Publish workflow end-to-end p99 | < 8 s (includes GCS upload + CDN invalidation enqueue) |
| Cloud Run min replicas (API) | 3 |
| Cloud Run min replicas (publish-worker) | 2 |
| Cloud Run min replicas (CDN-invalidation worker) | 1 (autoscale) |
| Cloud SQL Postgres CPU | shared with platform-services pool on the regional HA instance |
| GCS bucket | melmastoon-themes-<region> (Standard for last 90 days, Nearline 90+, Coldline 365+) |
10. Decision log (anchors)
- Why a separate service rather than a folder in
tenant-service— the publishing lifecycle (draft / preview / publish / rollback / cache invalidate) and the design-token vocabulary are heavy enough that mixing them with tenant CRUD would obscure both. Lifecycle, OCC, contrast checks, RTL derivation, and CDN integration are non-trivial concerns that benefit from isolation. - Why semantic tokens, not raw colour hex everywhere — the consumer apps depend on a stable token vocabulary; tenants change the values, never the names. This lets us evolve the design system (add
accent, deprecatemutedAlt) without coordinating with N tenants. - Why CSS variables + JSON, not a CSS file per tenant — the consumer apps are a single shared bundle; per-tenant CSS files would balloon the build matrix and bypass our compression / CDN economy. CSS variables let one stylesheet adapt at runtime; JSON lets React Native and email renderers consume the same source.
- Why immutable versions and atomic publish — design rollbacks must be safe and instant; the only way to guarantee that without "did I miss a field?" risk is to ban in-place edits on published versions.
- Why publish through GCS + CDN rather than directly from Postgres — edge latency. A Pashto guest in Kandahar should see their hotel's brand at 50 ms, not 500 ms. Postgres is canonical; GCS + Memorystore + CDN is for serving.
- Why we read AI provenance but never call models — every AI call must flow through
ai-orchestrator-servicefor routing, moderation, budget, HITL, andAIProvenanceper 02 §11. Suggested palettes and draft translations arrive as content with provenance attached; we persist provenance and require HITL approval before activation. - Why per-tenant CDN tags rather than per-bundle URL versioning alone — bundle URLs are content-hashed (immutable), but consumer apps cache the bundle URL itself in Memorystore for 1 h; the tag-based purge lets us invalidate that without waiting for TTL.
11. What this service depends on (libraries, ports, infrastructure)
- NestJS for presentation + DI composition root (out of the domain layer).
- Drizzle ORM for Postgres access in the infrastructure layer.
@google-cloud/pubsubfor outbox publishing and consumed-event subscription.@google-cloud/storagefor storing immutable bundle objects.@google-cloud/compute(Cloud CDN admin client) for cache-tag invalidation.- Memorystore (Redis) for published-bundle hot cache, preview-token cache, layout-preset registry cache.
color2kfor contrast calculation;@formatjs/intl-localefor locale resolution;postcss-logicalfor verifying logical-property usage;dompurify+jsdomfor sanitising rich-text in content blocks.- Ports the application layer depends on (interfaces only):
ThemeRepositoryThemeVersionRepositoryThemePublicationRepositoryContentBlockRepositoryNavigationConfigRepositoryBookingFlowConfigRepositoryEmailThemeRepositoryLocalePackRepositoryLayoutPresetRegistryRepositoryPreviewTokenRepositoryBundleStoragePort(GCS adapter)CdnInvalidationPort(Cloud CDN adapter)EventPublisher(outbox-backed)AssetIntegrityClient(callsfile-storage-serviceHEAD)AIClient(callsai-orchestrator-servicefor HITL palette / translation drafts)TenantConfigClient(reads per-tenant default locale, currency display fromtenant-service)Clock,IdGenerator,Hasher,ContrastChecker
The domain layer depends on nothing outside @ghasi/domain-primitives and the standard library. CI fails the build on any framework or I/O import inside src/domain/.
12. References
- Theme data model and DDL: 06 Data Models §4.12
- Event registry (theme topics): 04 Event-Driven Architecture §3 Property & Theme
- API conventions (Problem+JSON, OCC, idempotency): 05 API Design
- Multi-tenancy model (RLS, residency): 07 Security/Compliance/Tenancy, ADR-0002
- AI orchestration and provenance (palette + translation drafts): 08 AI Architecture
- Email theme contract used by
notification-service:services/notification-service/SERVICE_OVERVIEW.md§5 - Asset references (logo / hero / video):
services/file-storage-service/API_CONTRACTS.md - Naming, error codes: standards/NAMING.md, standards/ERROR_CODES.md
- Sibling: tenant-service, file-storage-service, notification-service, ai-orchestrator-service