@framework-cwf/contracts
v0.9.0
Published
Zod schemas and TypeScript types for every value that crosses a boundary (website-config, postMessage protocol, webhook payloads, embed URL builder).
Readme
@framework-cwf/contracts
Zod schemas and inferred TypeScript types for every value that crosses a
boundary between the Admin platform (booking-api / booking-web) and the
customer-website framework. A change to a schema here is intended to fail CI
on both sides simultaneously.
This package has no runtime code other than the schemas themselves. It is the
first dependency of every other @framework-cwf/* package.
What's in here
| Schema | Purpose |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| WebsiteConfigSchema | Top-level wrapper: { operational, marketing? } — the wire format. |
| OperationalConfigSchema | Existing booking-api payload (business, locations, staff, etc.). |
| MarketingConfigSchema | New admin-authored brand / copy / SEO content. |
| BrandSchema, HeroSchema, ValuePropsSchema, … | Marketing sub-blocks. |
| EMBED_VARIANTS, HEADING_FONTS, BODY_FONTS, ROUTE_KEYS | Const tuples — single source of truth for whitelists / route names. |
| SitePublishedEventSchema | site.published webhook body emitted by booking-api to the deploy controller. |
| signWebhookPayload, verifyWebhookSignature | HMAC-SHA256 helpers; verification uses crypto.timingSafeEqual. |
Inferred TypeScript types are exported alongside every schema (e.g. Brand
for BrandSchema).
Schema layout
WebsiteConfigSchema
├── operational (OperationalConfigSchema)
│ ├── business — name, tagline, contact, est, logo refs
│ ├── booking — theme + brand colour + header/show toggles
│ ├── locations[] — guid (UUID), name, address, hours[], serviceCategories[]
│ ├── staff[] — id, business_user_guid (UUID, optional), name, role
│ ├── hours[]? — present when published at top level instead of per-location
│ └── serviceCategories[]? — same: optional top-level shape
└── marketing? (MarketingConfigSchema)
├── brand — embedVariant, hex colours, fonts (whitelist), cornerRadius, density
├── hero — headline, subheadline, primaryCta, optional secondaryCta + image
├── valueProps — exactly 3 cards
├── about — headline + body
├── gallery[] — imageRef + altText (required, ≥4 chars)
├── testimonials[] — text + attribution + optional location/rating/imageRef
├── faqs — pages: Record<RouteKey, FaqEntry[]>
├── seo — canonicalHostname, defaults, robots (prod / non-prod), per-page overrides
└── pages — enabled: Record<RouteKey, boolean>RouteKey is 'home' | 'services' | 'about' | 'visit'.
Validation rules enforced
- All hex colour fields match
/^#[0-9a-fA-F]{6}$/. - All font fields must be members of
HEADING_FONTS/BODY_FONTS. - All image refs are non-empty strings.
- Gallery
altTextis required and at least 4 chars. seo.canonicalHostnamemust be a valid URL.- Robots directives must match the
(no)?index,(no)?followshape. location.guidmust be a valid UUID.staff.business_user_guidmust be a valid UUID when present (the field is optional — see "Notes on shape divergence" below).
What's new in 0.2.0
Seven additive, fully-optional fields. Every 0.1.x payload still parses without modification — back-compat is non-negotiable.
| Field | Where | Why |
| ----------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| business.businessType | OperationalConfig.business | schema.org LocalBusiness sub-type. Enum: hair_salon | barbershop | beauty_salon | spa | local_business. Pilot defaults to hair_salon when unset. |
| services[].priceCurrency | OperationalConfig.services[] | ISO 4217 currency. Whitelist: GBP | EUR | USD | AUD | CAD. Consumers fall back to GBP when unset. |
| business.addressStructured | OperationalConfig.business | schema.org PostalAddress — { streetAddress, addressLocality, addressRegion, postalCode, addressCountry, latitude?, longitude? }. Coexists with the freeform address string. |
| locations[].hoursStructured | OperationalConfig.locations[] | schema.org OpeningHoursSpecification — { dayOfWeek, opens, closes, closed? }[] with HH:mm times. Coexists with the freeform hours[] array. |
| testimonials[].verified | MarketingConfig.testimonials[] | Trust-badge flag. Optional — consumers treat undefined as false. |
| seo.pages[route].noindex | MarketingConfig.seo.pages | Boolean noindex alongside the legacy robots directive string. When both are set, noindex: true wins (resolution in @framework-cwf/seo). |
| seo.hreflang | MarketingConfig.seo | Per-locale alternates — { lang: BCP-47, href: URL }[]. Drives <link rel="alternate" hreflang> and Next.js Metadata.alternates.languages. |
New whitelist exports: BUSINESS_TYPES, PRICE_CURRENCIES, DAYS_OF_WEEK.
New schemas: BusinessTypeSchema, PriceCurrencySchema,
PostalAddressSchema, HoursStructuredEntrySchema, HreflangEntrySchema,
DayOfWeekSchema. New inferred types: BusinessType, PriceCurrency,
PostalAddress, HoursStructuredEntry, HreflangEntry, DayOfWeek.
Notes on shape divergence (operational config)
The two reference operational payloads —
sniply-barber-fe/config.js and sniply-barber-fe/uploads/config.json —
disagree on three things, and the schema is permissive across all three so
both parse without modification:
hoursplacement. Sometimes inside eachlocation, sometimes at the top level ofoperational. Both are accepted; both fields are optional.serviceCategoriesplacement. Same pattern — per-location or top-level.- Staff identifiers. Some payloads carry UUIDs in both
idandbusiness_user_guid; others carry slugs ("marcus") inidonly. So:staff.idisz.string()(any opaque identifier).staff.business_user_guidisz.string().uuid().optional().
British/US spelling in booking (primaryColour vs bg_color) is preserved
deliberately. Renaming would require a coordinated migration across
booking-api and every published config.
All operational schemas use .passthrough() so unknown fields (e.g.
staff.tier, staff.priceMod) are preserved through round-trips rather than
silently dropped.
Worked example — a complete website-config payload
import {
WebsiteConfigSchema,
type WebsiteConfig,
} from "@framework-cwf/contracts";
const payload: WebsiteConfig = {
operational: {
business: {
name: "SNIP-LY Barber",
tagline: "Sharp cuts. Clean lines.",
description: "London's modern barbershop brand.",
phone: "+44 20 7946 0100",
email: "[email protected]",
address: "31 South Molton Street, London, W1K 5RN",
logo: null,
icon_logo: null,
},
booking: {
theme: "dark",
primaryColour: "#ff0000",
bg_color: "#1a1a1a",
},
locations: [
{
guid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
name: "Mayfair",
address: null,
phone: null,
email: null,
image: null,
hours: [
{ day: "Mon", hours: "9 — 17" },
{ day: "Sat", hours: "closed", closed: true },
],
serviceCategories: [
{
id: "11",
name: "Classic Cuts",
description: "Scissor and clipper cuts",
icon: null,
priceImage: null,
services: [
{ name: "Classic Cut", duration: "30 min" },
{ name: "Skin Fade", duration: "45 min" },
],
},
],
},
],
staff: [
{
id: "c1d20001-0000-4000-8000-000000000001",
business_user_guid: "c1d20001-0000-4000-8000-000000000001",
name: "James Carter",
role: "OWNER",
image: null,
},
],
},
marketing: {
brand: {
embedVariant: "sharp",
primaryColor: "#ff0000",
bgColor: "#1a1a1a",
accentColor: "#00aa55",
headingFont: "Anton",
bodyFont: "Inter",
cornerRadius: 4,
density: "comfortable",
},
hero: {
headline: "Sharp cuts. Clean lines.",
subheadline: "Mayfair and Shoreditch. Walk-ins welcome.",
heroImageRef: "hero/main.jpg",
primaryCta: { label: "Book now", href: "/book" },
},
valueProps: [
{
iconRef: "icons/scissors",
title: "Craft",
body: "Trained cutters only.",
},
{
iconRef: "icons/clock",
title: "On time",
body: "Never wait past 5min.",
},
{ iconRef: "icons/star", title: "4.9★", body: "From 1,200+ reviews." },
],
about: { headline: "About SNIP-LY", body: "London's modern barbershop." },
gallery: [{ imageRef: "gallery/01.jpg", altText: "Skin fade detail shot" }],
testimonials: [
{ text: "Best fade in London.", attribution: "Tom R.", rating: 5 },
],
faqs: {
pages: {
home: [
{
question: "Do you take walk-ins?",
answer: "Yes — but booking guarantees a slot.",
},
],
},
},
seo: {
canonicalHostname: "https://snip-ly.co.uk",
defaults: {
titleTemplate: "%s | SNIP-LY Barber",
descriptionTemplate: "London barbers — %s.",
ogImageRef: "og/default.jpg",
},
robots: {
production: "index,follow",
nonProduction: "noindex,nofollow",
},
pages: {
"/": { title: "SNIP-LY Barber — London cuts and shaves" },
},
},
pages: {
enabled: { home: true, services: true, about: true, visit: true },
},
},
};
const parsed = WebsiteConfigSchema.parse(payload); // throws on validation failuresite.published webhook
booking-api emits a site.published webhook to the deploy controller after
every successful publish. The body matches SitePublishedEventSchema; two
headers (X-Sniply-Signature, X-Sniply-Timestamp) carry the HMAC-SHA256
signature over ${timestamp}.${rawBody}. See the JSDoc at the top of
src/webhook.ts for the full response contract (202 / 401 /
404 / 500), the 1-hour exponential-backoff retry policy, and the
version_id-as-idempotency-key convention.
import {
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TIMESTAMP_HEADER,
signWebhookPayload,
verifyWebhookSignature,
} from "@framework-cwf/contracts";
// Sender (booking-api)
const payload = JSON.stringify(event);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = signWebhookPayload({ payload, timestamp, secret });
await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
[WEBHOOK_SIGNATURE_HEADER]: signature,
[WEBHOOK_TIMESTAMP_HEADER]: timestamp,
},
body: payload,
});
// Receiver (deploy controller)
const result = verifyWebhookSignature({
payload: rawBody,
signature: req.headers[WEBHOOK_SIGNATURE_HEADER.toLowerCase()],
timestamp: req.headers[WEBHOOK_TIMESTAMP_HEADER.toLowerCase()],
secret,
});
if (!result.valid) return res.status(401).json({ error: "invalid_signature" });Commands
pnpm --filter @framework-cwf/contracts typecheck
pnpm --filter @framework-cwf/contracts test