API_CONTRACTS — theme-config-service
Sibling: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · EVENT_SCHEMAS · SECURITY_MODEL
Platform anchors:
docs/05-api-design.md·docs/standards/NAMING.md·docs/standards/ERROR_CODES.md
This document is the wire contract between theme-config-service and its callers (BFFs, the backoffice console, the hosted booking flow, and the meta-search detail-page CDN edge worker). Conventions follow docs/05-api-design.md verbatim — Problem+JSON errors, OpenAPI 3.1, ULID identifiers, OCC via If-Match, idempotency via Idempotency-Key, cursor-based pagination.
The OpenAPI specification of record is services/theme-config-service/openapi/theme-config-service.openapi.yaml (generated from this document at build time and validated in CI).
1. Base URL & versioning
| Environment | Base URL | Notes |
|---|---|---|
| Local dev | http://localhost:7041 | direct Cloud Run emulator |
| Staging | https://api.staging.melmastoon.app/theme-config/v1 | behind the platform API gateway |
| Production | https://api.melmastoon.app/theme-config/v1 | behind the platform API gateway |
| CDN edge (read-only) | https://cdn.melmastoon.app/themes/<themeId>/published.json | served by Cloud CDN, origin = GCS bucket |
API version: v1. Breaking changes require a new major (v2); additive changes are backwards-compatible per docs/05-api-design.md §3.
2. Common headers
2.1 Request
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <jwt> | yes (except CDN edge) | Issued by iam-service; carries tenantId, roles, propertyScope |
X-Tenant-Id | yes | Must match JWT claim; gateway rejects mismatch |
X-Request-Id | yes (gateway-generated) | ULID; used as correlationId in events + logs |
X-Idempotency-Key | required for unsafe requests | ULID; window 24h |
If-Match | required for PATCH/PUT/some POST (e.g. publish) | Aggregate version integer |
Accept-Language | optional | RFC 5646 BCP 47; default en-US |
X-Property-Id | optional | Property-scoped operations (Phase 2) |
2.2 Response
| Header | Notes |
|---|---|
ETag | Aggregate version quoted strong (e.g. "7") |
Cache-Control | no-store for authoring APIs; public, max-age=300, s-maxage=86400, stale-while-revalidate=60 for CDN bundle |
Content-Language | echoed back when localised |
X-Request-Id | echoed back |
X-Tenant-Id | echoed back |
3. Error model
All non-2xx responses are RFC 9457 Problem Details with the platform-extended fields per docs/05-api-design.md §6:
{
"type": "https://errors.melmastoon.app/MELMASTOON.THEME.VERSION_IMMUTABLE",
"title": "Theme version is no longer editable",
"status": 409,
"detail": "ThemeVersion thv_01J9F8K2X4S3D5W7 has status 'published' and cannot be patched.",
"instance": "/v1/theme-versions/thv_01J9F8K2X4S3D5W7",
"errorCode": "MELMASTOON.THEME.VERSION_IMMUTABLE",
"tenantId": "tnt_01J9...",
"requestId": "req_01J9...",
"correlationId": "req_01J9...",
"timestamp": "2026-04-23T10:14:55.211Z",
"violations": []
}
Field-level validation errors use 400 MELMASTOON.THEME.VALIDATION_FAILED with a violations[] array { field, code, message }.
The complete code catalogue lives in DOMAIN_MODEL §11 and is registered in the platform docs/standards/ERROR_CODES.md.
4. Resources
4.1 Themes
GET /themes
List themes for the current tenant.
GET /themes?limit=20&cursor=eyJ...&propertyId=null HTTP/1.1
Authorization: Bearer ...
X-Tenant-Id: tnt_01J9...
Response 200
{
"data": [
{
"id": "thm_01J9F8K2X4S3D5W7AAAA",
"tenantId": "tnt_01J9...",
"propertyId": null,
"name": "Default brand",
"scope": "tenant",
"defaultLocale": "ps-AF",
"enabledLocales": ["ps-AF", "fa-AF", "en-US"],
"fallbackChain": ["ps-AF", "en-US"],
"status": "active",
"activePublication": {
"id": "thp_01J9...",
"themeVersionId": "thv_01J9...",
"ordinal": 7,
"publishedAt": "2026-04-20T08:14:01.000Z",
"bundleUrl": "https://cdn.melmastoon.app/themes/thm_01J.../published.json"
},
"version": 9,
"createdAt": "2026-01-12T08:14:01.000Z",
"updatedAt": "2026-04-20T08:14:01.000Z"
}
],
"page": { "nextCursor": null, "limit": 20 }
}
RBAC: theme:read.
Errors: 401, 403.
POST /themes
Create an additional theme (Phase 2 property override; Phase 1 is provisioned automatically by ProvisionDefaultThemeUseCase).
POST /themes
Content-Type: application/json
X-Idempotency-Key: ulid
{
"propertyId": "prp_01J9...", // optional, null for tenant scope
"name": "Kabul Continental — main building",
"scope": "property",
"defaultLocale": "ps-AF",
"enabledLocales": ["ps-AF", "fa-AF", "en-US"],
"fallbackChain": ["ps-AF", "en-US"],
"cloneFromThemeId": "thm_01J..." // optional; clones active version
}
Response 201 — full Theme representation as in §4.1 GET.
RBAC: theme:author + property scope when scope === 'property'.
Errors: 400 VALIDATION_FAILED, 403, 409 PROPERTY_THEME_ALREADY_EXISTS, 404 PARENT_THEME_NOT_FOUND.
GET /themes/{themeId}
Returns the same shape as the list element. 404 if not found or RLS-hidden.
PATCH /themes/{themeId}
Edit theme-level metadata (name, default locale, enabled locales, fallback chain). Token / content edits go through PATCH /theme-versions/....
PATCH /themes/thm_01J...
If-Match: 9
Content-Type: application/json
{
"name": "Default brand v2",
"defaultLocale": "ps-AF",
"enabledLocales": ["ps-AF", "fa-AF", "en-US", "ar-SA"],
"fallbackChain": ["ps-AF", "en-US"]
}
Response 200 — updated Theme; ETag = new version.
Errors: 400 VALIDATION_FAILED, 412 PRECONDITION_FAILED, 409 LOCALE_IN_USE (when removing a locale still used by published copy).
POST /themes/{themeId}/locales
Adds an enabled locale. Body { "locale": "ar-SA" }. Emits theme.locale_added.v1. RBAC: theme:author.
DELETE /themes/{themeId}/locales/{locale}
Removes an enabled locale. Rejects when locale === defaultLocale or the locale has live copy. Emits theme.locale_removed.v1.
POST /themes/{themeId}/rollback
Roll back to a previously published version of this theme.
POST /themes/thm_01J.../rollback
X-Idempotency-Key: ulid
{
"toVersionId": "thv_01J...",
"reason": "Investor preview launched broken hero image"
}
Response 202
{
"publicationId": "thp_01J...",
"themeVersionId": "thv_01J...",
"bundleUrl": "https://cdn.melmastoon.app/themes/thm_01J.../published.json",
"cdnInvalidationId": "ci_01J...",
"estimatedCdnPropagationSeconds": 45
}
Errors: 400 ROLLBACK_TARGET_NOT_PREVIOUSLY_PUBLISHED, 409 PUBLISH_CONFLICT.
4.2 Theme versions
GET /themes/{themeId}/versions
GET /themes/thm_01J.../versions?status=draft,preview_ready&limit=20
Response: paginated list of version summaries.
POST /themes/{themeId}/versions
POST /themes/thm_01J.../versions
X-Idempotency-Key: ulid
{
"source": "clone_active" // or "blank"
}
Response 201
{
"id": "thv_01J...",
"themeId": "thm_01J...",
"ordinal": 8,
"status": "draft",
"version": 1,
"createdAt": "2026-04-23T10:00:00.000Z",
"createdBy": "usr_01J..."
}
GET /theme-versions/{versionId}
Returns the expanded version with embedded child aggregates (tokens, layout selections, blocks, navs, booking-flow, email-theme, locale-pack summary). Used by the backoffice editor.
{
"id": "thv_01J...",
"themeId": "thm_01J...",
"ordinal": 8,
"status": "draft",
"version": 4,
"tokens": {
"color": { "primary": "#0F4C81", "primaryHover": "#0C3F6C", "textOnPrimary": "#FFFFFF", "...": "..." },
"typography": { "fontFamilyDisplay": "\"Iran Sans\", system-ui, sans-serif", "...": "..." },
"spacing": { "scaleBase": 4, "0": 0, "1": 4, "...": 64, "start": "logical", "end": "logical" },
"radius": { "sm": 4, "md": 8, "lg": 16 },
"shadow": { "low": "0 1px 2px rgba(0,0,0,0.1)", "...": "..." },
"motion": { "fast": 120, "base": 200, "slow": 320, "ease": "cubic-bezier(0.2,0.8,0.2,1)" },
"direction": "auto"
},
"layoutSelections": {
"home": { "presetKey": "hero-with-search", "variantKey": "video-hero" },
"detail": { "presetKey": "mosaic-grid", "variantKey": "default" },
"search": { "presetKey": "list-with-map", "variantKey": "right-map" }
},
"blocks": [ /* see §4.3 */ ],
"navs": [ /* see §4.4 */ ],
"bookingFlow": { /* see §4.5 */ },
"emailTheme": { /* see §4.6 */ },
"localePacks": [ { "locale": "ps-AF", "completeness": 0.92 }, { "locale": "en-US", "completeness": 1.0 } ],
"validation": {
"warnings": [
{ "code": "MELMASTOON.THEME.LOCALE_INCOMPLETE", "field": "localePacks[ps-AF]", "message": "23 keys missing for ps-AF; will fall back to en-US." }
]
},
"createdAt": "2026-04-23T10:00:00.000Z",
"createdBy": "usr_01J...",
"publishedAt": null,
"publishedBy": null
}
PATCH /theme-versions/{versionId}
PATCH /theme-versions/thv_01J...
If-Match: 4
Content-Type: application/merge-patch+json
{
"tokens": {
"color": { "primary": "#0E437A", "primaryHover": "#0B3866" }
},
"layoutSelections": {
"home": { "presetKey": "hero-with-search", "variantKey": "static-hero" }
}
}
Response 200 — same shape as GET; ETag = new version.
Errors: 400 VALIDATION_FAILED (with violations[]), 409 VERSION_IMMUTABLE, 412 PRECONDITION_FAILED.
POST /theme-versions/{versionId}/preview
Mints a preview token. Body { "ttlHours": 24, "note": "share with the GM" }.
Response 201
{
"previewUrl": "https://preview.melmastoon.app/eyJ0aWQiOiJwdnRfMDFKLi4iLCJzIjoiQUJDRC4uIn0",
"tokenId": "pvt_01J...",
"themeVersionId": "thv_01J...",
"expiresAt": "2026-04-24T10:00:00.000Z"
}
The previewUrl carries the secret only at mint time; subsequent reads show only tokenId + expiresAt. Validation is documented in SECURITY_MODEL §6.
DELETE /theme-versions/{versionId}/preview/{tokenId}
Revoke a preview token. 204 No Content.
POST /theme-versions/{versionId}/publish
POST /theme-versions/thv_01J.../publish
If-Match: 4
X-Idempotency-Key: ulid
{
"releaseNotes": "Spring promo, new hero, AR locale launch"
}
Response 202
{
"publicationId": "thp_01J...",
"themeVersionId": "thv_01J...",
"bundleUrl": "https://cdn.melmastoon.app/themes/thm_01J.../published.json",
"bundleSha256": "9f2c8e...",
"bundleSizeGzippedBytes": 27341,
"cdnInvalidationId": "ci_01J...",
"estimatedCdnPropagationSeconds": 45
}
Errors:
| Status | Code | When |
|---|---|---|
| 400 | MELMASTOON.THEME.VALIDATION_FAILED | tokens fail final validation |
| 400 | MELMASTOON.THEME.RTL_VARIANT_MISSING | RTL parity missing |
| 403 | MELMASTOON.AI.HITL_REQUIRED | AI-drafted content not approved by HITL-eligible role |
| 409 | MELMASTOON.THEME.PUBLISH_CONFLICT | concurrent publish in flight |
| 409 | MELMASTOON.THEME.ASSET_BROKEN | one or more MediaRef URLs not reachable |
| 412 | MELMASTOON.PLATFORM.PRECONDITION_FAILED | If-Match mismatch |
| 422 | MELMASTOON.THEME.LOCALE_INCOMPLETE | only when strictLocales=true query param is set |
4.3 Content blocks
GET /theme-versions/{versionId}/blocks — list.
POST /theme-versions/{versionId}/blocks — create.
PATCH /content-blocks/{blockId} — update (OCC).
DELETE /content-blocks/{blockId} — delete (rejects if referenced by a published navigation kind: 'route', target: '/p/<slug>' whose slug is owned by this block).
Body example for a gallery block:
{
"surface": "home",
"kind": "gallery",
"ordinal": 30,
"visibility": "all",
"body": {
"ps-AF": { "format": "structured", "value": { "title": "د کور انځورونه", "items": [{ "ref": { "kind": "image", "url": "https://...", "alt": { "ps-AF": "مخامخ نظر" } } }] } },
"en-US": { "format": "structured", "value": { "title": "Property gallery", "items": [{ "ref": { "kind": "image", "url": "https://...", "alt": { "en-US": "Front view" } } }] } }
},
"meta": {
"kind": "gallery",
"layout": "masonry",
"items": [{ "kind": "image", "url": "https://...", "ratio": "4:3" }]
}
}
Validation:
- Markup →
dompurifyallow-list (markdown: CommonMark + safe HTML; structured: schema-validated perkind). meta.kindmust equalkind.body[locale]is mandatory fordefaultLocale; other locales optional (warning at draft, never error).
4.4 Navigation
GET /theme-versions/{versionId}/navs — list per surface.
PATCH /navs/{navId} — replace items[] tree (idempotent full-tree replace).
Body:
{
"items": [
{
"id": "nvi_01J...",
"kind": "route",
"target": "/",
"label": { "ps-AF": "کور", "en-US": "Home" },
"openInNewTab": false,
"ordinal": 10
},
{
"id": "nvi_01J...",
"kind": "external",
"target": "https://chain.example.com/loyalty",
"label": { "en-US": "Loyalty club" },
"rel": ["nofollow"],
"ordinal": 20,
"children": []
}
]
}
Constraints: max depth 3, max 24 items per surface; ordinal must be unique among siblings.
4.5 Booking flow config
GET /theme-versions/{versionId}/booking-flow — single resource.
PUT /booking-flow-configs/{id} — full replace, OCC.
Body:
{
"version": 3,
"steps": [
{ "id": "guest_count", "ordinal": 10, "required": true, "fields": ["adults", "children"] },
{ "id": "dates", "ordinal": 20, "required": true, "fields": ["checkIn", "checkOut"] },
{ "id": "guest_info", "ordinal": 30, "required": true, "fields": ["fullName", "email", "phone", "passportNumber?", "nationality?"] },
{ "id": "extras", "ordinal": 40, "required": false, "fields": ["airportTransfer?", "earlyCheckin?"] },
{ "id": "payment", "ordinal": 50, "required": true, "fields": ["paymentMethod"] }
],
"toggles": {
"capturePassportNumber": true,
"requestAirportTransfer": true,
"allowGiftBooking": false,
"allowCashOnArrival": true,
"allowGuestCheckout": true
},
"consent": {
"showMarketingOptIn": true,
"marketingOptInDefault": false,
"showTermsCheckbox": true,
"termsUrl": "/p/policies/terms"
}
}
Validation per DOMAIN_MODEL §10.3.
4.6 Email theme
GET /theme-versions/{versionId}/email-theme — single resource.
PUT /email-themes/{id} — full replace, OCC.
Body:
{
"version": 2,
"tokens": {
"color": {
"background": "#FFFFFF",
"surface": "#F7F7F8",
"textPrimary": "#111827",
"textMuted": "#6B7280",
"primary": "#0F4C81",
"textOnPrimary": "#FFFFFF",
"border": "#E5E7EB"
},
"typography": {
"fontFamilyEmail": "Helvetica, Arial, sans-serif",
"sizeBody": 14,
"sizeHeading": 20,
"lineHeight": 1.5
},
"spacing": { "tight": 8, "base": 16, "loose": 24 }
},
"logoRef": { "kind": "image", "url": "https://...", "widthPx": 160, "heightPx": 40, "alt": { "en-US": "Logo" } },
"footer": {
"addressLine": "Kabul Continental, Wazir Akbar Khan, Kabul",
"supportEmail": "support@kabulcontinental.com",
"unsubscribeStrategy": "transactional_only"
}
}
Email tokens are intentionally a tighter palette than web tokens because email clients render only inline CSS + a tiny safe-list of properties; see DOMAIN_MODEL §10.4.
4.7 Locale packs
GET /theme-versions/{versionId}/locale-packs — list.
GET /theme-versions/{versionId}/locale-packs/{locale} — full pack.
PUT /locale-packs/{id} — full replace, OCC.
Body:
{
"version": 5,
"locale": "ps-AF",
"entries": {
"search.cta.searchAvailability": "د شتون لټون",
"search.cta.viewRoom": "خونه وګورئ",
"booking.errors.dateRangeInvalid": "د نېټې لړۍ ناسمه ده",
"footer.copyright": "© {year} {tenantName}. ټول حقوق خوندي دي."
}
}
Placeholder syntax: {name} (ICU simpleArg) and {count, plural, one {..} other {..}} (ICU plural). Validated server-side; mismatched placeholders vs the defaultLocale pack → 400 LOCALE_PLACEHOLDER_MISMATCH with field-level violations.
4.8 Layout preset registry
Read-only platform-global catalogue.
GET /layout-presets?surface=home →
{
"data": [
{
"id": "lpr_01J...",
"key": "hero-with-search",
"surface": "home",
"supportedVariants": ["video-hero", "static-hero"],
"supportsRtl": true,
"minViewportPx": 360,
"thumbnailUrl": "https://...",
"documentationUrl": "https://docs.melmastoon.app/themes/layouts/hero-with-search"
}
]
}
4.9 AI assistance (HITL-gated)
Per docs/08-ai-architecture.md, every AI surface is HITL-gated by default in this service.
POST /themes/{themeId}/ai-suggest-palette
Body { "primaryColor": "#0F4C81", "brandKeywords": ["calm", "trustworthy", "Hazara turquoise"] }.
Response 200
{
"suggestionId": "ais_01J...",
"draftedTokens": {
"color": {
"primary": "#0F4C81",
"primaryHover": "#0C3F6C",
"primaryActive": "#082D4F",
"textOnPrimary": "#FFFFFF",
"secondary": "#13A3A1",
"secondaryHover": "#0F8A88",
"textOnSecondary": "#FFFFFF",
"accent": "#F2A341",
"textOnAccent": "#1A1A1A"
}
},
"provenance": {
"model": "claude-3-5-sonnet-2026-01",
"promptHash": "sha256:...",
"tokensIn": 412, "tokensOut": 138,
"createdAt": "2026-04-23T10:14:55.211Z",
"redactionApplied": false
},
"hitlTaskUrl": "https://backoffice.melmastoon.app/themes/thm_01J.../hitl/ais_01J..."
}
POST /themes/{themeId}/ai-suggest-palette/{suggestionId}/apply
HITL approver applies the suggestion to a target draft version. Body { "themeVersionId": "thv_01J...", "approverNote": "Approved with darker hover" }. The use case writes through PatchThemeVersionUseCase and persists aiProvenance.
Symmetric endpoints exist for translation drafts (/themes/{id}/ai-draft-translations + …/apply) and content drafts (/content-blocks/{id}/ai-draft + …/apply).
4.10 Read endpoints for runtime delivery
These are the hot-path endpoints consumed by the BFFs and the booking flow; they bypass the gateway in favour of Cloud CDN whenever possible.
GET /public/themes/{themeId}/published.json (cached at edge)
Returns the canonical bundle JSON. Headers:
Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=60
ETag: "<bundleSha256>"
Vary: Accept-Encoding
Body: the full bundle artefact described in APPLICATION_LOGIC §5.
GET /public/themes/by-host/{host}/published.json
Resolves host → tenantId → themeId then returns the published bundle. Used by the meta-search hotel-detail edge worker.
GET /public/preview/{token} — preview bundle
Validates token = base64url(tokenId + ":" + secret); checks previewToken.tokenHash === sha256(secret); returns the bundle as above with Cache-Control: private, no-store.
GET /internal/email-theme/{themeId} — for notification-service
mTLS-only (workload identity). Returns the EmailTheme block plus the resolved tokens needed to render MJML/HTML emails.
5. Pagination
All list endpoints use cursor pagination per docs/05-api-design.md §5:
?limit=20&cursor=eyJrIjoidGh2XzAxSi4uIn0
Response contains page.nextCursor (null when no more) and page.limit. Server-imposed limit cap: 100.
6. Idempotency semantics
X-Idempotency-Key is required for every non-GET request. Behaviour:
| Outcome of original | Replay outcome |
|---|---|
| 2xx | Same status + body returned; no side effects re-executed. |
| 4xx | Same status + body returned. |
| 5xx | Treated as no-op; replay re-executes the use case. |
| Concurrent in-flight | 409 MELMASTOON.PLATFORM.IDEMPOTENCY_IN_PROGRESS; client retries after 1 s. |
Window: 24h. Storage: idempotency_keys table per DATA_MODEL §5.
7. Rate limits
Enforced at the gateway; this service additionally applies per-tenant guards:
| Endpoint group | Limit |
|---|---|
| Authoring (PATCH/POST/DELETE) | 60 rpm per actor, 600 rpm per tenant |
POST .../publish | 10 rpm per tenant (publish should be deliberate) |
| AI assistance | 30 rpm per tenant (orchestrator-budget protected) |
| Public bundle reads | unlimited at CDN; origin shielded by GCS rate limits |
| Preview reads | 60 rpm per tokenHash; 600 rpm per tenant |
Exceeded → 429 MELMASTOON.PLATFORM.RATE_LIMITED with Retry-After header.
8. OpenAPI generation
The OpenAPI document is generated from the NestJS controllers via @nestjs/swagger and validated in CI:
pnpm --filter @ghasi/theme-config-service openapi:generate
pnpm --filter @ghasi/theme-config-service openapi:validate # spectral against platform ruleset
pnpm --filter @ghasi/theme-config-service openapi:diff # vs the previous published spec; breaking changes fail
Consumers (BFFs, backoffice, the booking flow SDK) regenerate clients from the published spec; no hand-written client allowed.
9. Cross-references
- Token / content shapes:
DOMAIN_MODEL - Use cases backing each endpoint:
APPLICATION_LOGIC §2 - Event payloads emitted:
EVENT_SCHEMAS - Auth + RBAC:
SECURITY_MODEL,docs/07-security-compliance-tenancy.md - Platform conventions:
docs/05-api-design.md