Skip to main content

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

EnvironmentBase URLNotes
Local devhttp://localhost:7041direct Cloud Run emulator
Staginghttps://api.staging.melmastoon.app/theme-config/v1behind the platform API gateway
Productionhttps://api.melmastoon.app/theme-config/v1behind the platform API gateway
CDN edge (read-only)https://cdn.melmastoon.app/themes/<themeId>/published.jsonserved 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

HeaderRequiredNotes
Authorization: Bearer <jwt>yes (except CDN edge)Issued by iam-service; carries tenantId, roles, propertyScope
X-Tenant-IdyesMust match JWT claim; gateway rejects mismatch
X-Request-Idyes (gateway-generated)ULID; used as correlationId in events + logs
X-Idempotency-Keyrequired for unsafe requestsULID; window 24h
If-Matchrequired for PATCH/PUT/some POST (e.g. publish)Aggregate version integer
Accept-LanguageoptionalRFC 5646 BCP 47; default en-US
X-Property-IdoptionalProperty-scoped operations (Phase 2)

2.2 Response

HeaderNotes
ETagAggregate version quoted strong (e.g. "7")
Cache-Controlno-store for authoring APIs; public, max-age=300, s-maxage=86400, stale-while-revalidate=60 for CDN bundle
Content-Languageechoed back when localised
X-Request-Idechoed back
X-Tenant-Idechoed 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:

StatusCodeWhen
400MELMASTOON.THEME.VALIDATION_FAILEDtokens fail final validation
400MELMASTOON.THEME.RTL_VARIANT_MISSINGRTL parity missing
403MELMASTOON.AI.HITL_REQUIREDAI-drafted content not approved by HITL-eligible role
409MELMASTOON.THEME.PUBLISH_CONFLICTconcurrent publish in flight
409MELMASTOON.THEME.ASSET_BROKENone or more MediaRef URLs not reachable
412MELMASTOON.PLATFORM.PRECONDITION_FAILEDIf-Match mismatch
422MELMASTOON.THEME.LOCALE_INCOMPLETEonly 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 → dompurify allow-list (markdown: CommonMark + safe HTML; structured: schema-validated per kind).
  • meta.kind must equal kind.
  • body[locale] is mandatory for defaultLocale; 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 originalReplay outcome
2xxSame status + body returned; no side effects re-executed.
4xxSame status + body returned.
5xxTreated as no-op; replay re-executes the use case.
Concurrent in-flight409 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 groupLimit
Authoring (PATCH/POST/DELETE)60 rpm per actor, 600 rpm per tenant
POST .../publish10 rpm per tenant (publish should be deliberate)
AI assistance30 rpm per tenant (orchestrator-budget protected)
Public bundle readsunlimited at CDN; origin shielded by GCS rate limits
Preview reads60 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