Types and contract reference
<!-- Generated by pnpm docs:build (docs-reference) from packages/shared/src/index.ts and recurrence.ts. Do not edit by hand; edit the source and run the command. -->
Every exported type, constant and helper of @spillway-cms/shared (MIT), the contract the server, the admin, the SDK and the fleet build against, with the endpoint notes written next to them in the source (view on GitHub). The HTTP API in OpenAPI form is openapi.json.
Contents
- Content model
- Entities and revisions
- Puck page data (mirror of @puckeditor/core Data, kept dependency-free)
- Users, roles, permissions
- Site and media
- API contract
- Redirects and 404 log
- Collections (saved queries, "Views" for marketers)
- Site search
- Forms and lead capture
- Editorial review
- AI
- Phase 3 contract
- Module system contract
- Phase 2b editorial contract
- Taxonomy
- Image derivatives
- Rich text
- Shared blocks (reusable component library)
- Date ranges and recurrence
- Exposed filters and pagination for visitors
- Site alerts
- Analytics integrations
- Kits (config as code)
- Bulk actions and clone
- Enterprise sign-in (SSO)
- Personalization, A/B testing and cookie-free analytics
- Theme tokens: import, export and the stylesheet
- Brand area: drafts, publishing and history
- Shared brands (multisite) and brand kits
- Brand import (Brand → Import brand)
- Block styles (Brand → Styles)
- Style guide (Brand → Style guide, /brand)
- Navigation menus
- Workstreams
- Localization contract (Phase 3)
- Multisite and department delegation (Phase 3)
- Registry (the server side; enabled per instance with REGISTRY_HOST=1, meant for spillwaycms.com)
- Importer
- Importer: theme, admin and CLI (Agent C)
- SCIM 2.0 provisioning (RFC 7643, RFC 7644)
- Headless front ends: the public render API, webhooks and the adapters (Phase 4)
- Translation management systems (TMS)
- Digital asset management (DAM) connectors
- CDN purge (docs/caching.md)
- Onboarding: the getting started checklist and the page builder tour
- Backups (docs/install.md "Backups")
- Status (docs/install.md "Status")
- Shared saved views
- Recurrence helpers
Content model
FieldType
Type alias.
type FieldType = | 'text' // short single-line text
| 'textarea' // multi-line plain text
| 'richtext' // JSON block document (Tiptap/ProseMirror JSON); v0.1 may store simple HTML string
| 'number'
| 'boolean'
| 'date' // ISO 8601 date or datetime string
| 'select' // one of `options`
| 'reference' // id of an entity of type `refType`
| 'media' // id of a media item
| 'link' // { href: string; label?: string; newTab?: boolean }
| 'json' // arbitrary JSON
| 'layout' // Puck page data (PuckData) — the visual page builder document
| 'daterange' // DateRangeValue: { start, end?, allDay?, timezone?, recurrence? } (see recurrence.ts)
| 'taxonomy'
FieldDef
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | machine name, ^[a-z][a-z0-9_]*$ |
label | string | |
type | FieldType | |
required? | boolean | |
multiple? | boolean | array of the type |
help? | string | shown under the field in the admin |
aiHint? | string | what belongs in this field; read by AI tasks |
options? | string[] | for 'select' |
refType? | string | for 'reference': content type key |
vocabulary? | string | for 'taxonomy': Vocabulary.key |
default? | unknown | |
translatable? | boolean | default true for text-like fields |
searchable? | boolean | include in site search index (default true for text) |
ContentType
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | ^[a-z][a-z0-9_]*$, e.g. "page", "post", "case_study" |
label | string | "Case study" |
labelPlural | string | "Case studies" |
description? | string | |
icon? | string | optional icon name for the admin |
hasPath | boolean | entities get a public URL |
pathPattern? | string | e.g. "/blog/{slug}", "/{slug}"; tokens: {slug}, {id}, {type}, {yyyy}, {mm} |
titleField | string | which field is the display title (default "title") |
fields | FieldDef[] | |
createdAt | string | |
updatedAt | string |
Entities and revisions
EntityStatus
Type alias.
type EntityStatus = 'draft' | 'review' | 'published' | 'archived'
Entity
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | uuid v7 / ulid-like, string |
type | string | ContentType.key |
siteId | string | |
locale | string | BCP 47, e.g. "en" |
status | EntityStatus | |
title | string | denormalised display title |
slug | `string | null` |
path | `string | null` |
authorId | string | |
data | Record<string, unknown> | user-defined fields, keyed by FieldDef.key |
currentRevisionId | string | latest saved revision (draft or published) |
publishedRevisionId | `string | null` |
createdAt | string | |
updatedAt | string | |
publishedAt | `string | null` |
publishAt? | `string | null` |
unpublishAt? | `string | null` |
lastScheduleError? | `string | null` |
sectionId? | `string | null` |
workstreamId? | `string | null` |
inWorkstream? | boolean | Set on reads made inside a workstream (X-Spillway-Workstream) when the workstream changes this entity; data and title are then the branch version. |
workstreamAction? | `'update' | 'create' |
translationGroupId? | string | Localization (Phase 3): translations of the same content share translationGroupId (the source's own id); the source translation has isSource: true. translationStatus is 'outdated' when the source changed after this translation's last save. Always set on entities the server returns; optional in the type so hand-built entities keep compiling. |
isSource? | boolean | |
translationStatus? | TranslationStatus |
Revision
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
entityId | string | |
number | number | 1, 2, 3 ... per entity |
status | EntityStatus | status at the time of the snapshot |
title | string | |
data | Record<string, unknown> | |
authorId | string | |
note? | string | |
createdAt | string | |
workstreamId? | `string | null` |
Puck page data (mirror of @puckeditor/core Data, kept dependency-free)
PuckComponentData
Interface.
| Member | Type | Notes |
|---|---|---|
type | string | |
props | { id: string; [key: string]: unknown } | |
readOnly? | Record<string, boolean> |
PuckData
Interface.
| Member | Type | Notes |
|---|---|---|
content | PuckComponentData[] | |
root | { props?: Record<string, unknown> } | |
zones? | Record<string, PuckComponentData[]> |
Users, roles, permissions
Role
Type alias.
type Role = 'viewer' | 'author' | 'editor' | 'manager' | 'admin'
User
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
email | string | |
name | string | |
roles | Role[] | |
createdAt | string | |
lastLoginAt | `string | null` |
notifyOnReview? | boolean | Per-user notification preferences (Phase 2b); default true. PUT /api/auth/me. |
notifyOnComment? | boolean | |
authProvider? | string | How this user signs in: 'password' or the slug of the SSO provider that created or linked the account. |
externalId? | `string | null` |
siteRoles? | UserSiteRole[] | Multisite: per-site and per-section role assignments (GET /api/users and /api/users/:id carry them; see the multisite contract). |
sectionRoles? | UserSectionRole[] | |
uiLocale? | `string | null` |
active? | boolean | False once the account was deactivated (SCIM active: false or DELETE): no sign-in by any method, sessions and API tokens revoked. Default true. |
Permission
Type alias.
type Permission = | 'content.view_drafts'
| 'content.create'
| 'content.edit_own'
| 'content.edit_any'
| 'content.delete'
| 'content.submit_review'
| 'content.publish'
| 'content.unpublish'
| 'media.upload'
| 'media.delete'
| 'types.manage'
| 'site.settings'
| 'users.manage'
| 'forms.manage'
| 'forms.view_submissions'
| 'ai.use'
| 'sites.manage' // multisite: create, edit and delete sites (admin only)
| 'registry.publish' // publish packages to a registry instance (every role; see the registry contract)
| 'imports.manage' // the domain importer: create, run, review and land import jobs (admin; see the importer contract)
| 'views.share' // saved views shared with everyone on a site or with chosen roles (manager, admin; see the shared views contract)
| 'content.translate'
ROLE_PERMISSIONS
Constant of type Record<Role, Permission[]>.
const ROLE_PERMISSIONS = {
viewer: ['content.view_drafts', 'registry.publish'],
author: ['content.view_drafts', 'content.create', 'content.edit_own', 'content.submit_review', 'media.upload', 'ai.use', 'registry.publish'],
editor: [
'content.view_drafts', 'content.create', 'content.edit_own', 'content.edit_any', 'content.delete',
'content.submit_review', 'content.publish', 'content.unpublish', 'media.upload', 'media.delete',
'forms.view_submissions', 'ai.use', 'registry.publish', 'content.translate',
],
manager: [
'content.view_drafts', 'content.create', 'content.edit_own', 'content.edit_any',
// …
hasPermission()
Function.
function hasPermission(roles: Role[], permission: Permission): boolean
Site and media
SiteSettings
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | "default" for v0.1 |
name | string | |
tagline? | string | |
baseUrl? | string | e.g. https://example.org |
defaultLocale | string | "en" |
locales | string[] | |
localePrefixDefault? | boolean | Prefix paths of the default locale too (/en/about instead of /about). Default false; see the localization contract. |
homePageId | `string | null` |
theme | ThemeTokens | |
seo | `{ titleSuffix?: string; defaultDescription?: string; ogImageMediaId?: string | null; robots?: string; llmsTxt?: boolean }` |
identity? | SiteIdentity | Brand → Identity: logos and favicon. Written by publishing the brand draft (or by PUT /api/site { identity }). |
blockStyles? | BlockStyle[] | Brand → Styles as published: the site's own block styles (built-in styles it edited and styles it added), never the untouched built-ins. Written only by publishing the brand draft; PUT /api/site ignores it. See "Block styles". |
brandGuide? | BrandGuideSettings | Brand → Style guide as published: whether the style guide is public at BRAND_GUIDE_PATH (/brand). Off when absent. Written only by publishing the brand draft; PUT /api/site ignores it. See "Style guide". |
brandParent? | string | Brand inheritance (multisite, see "Shared brands"): the id of the site whose published brand this site inherits (set by an instance admin with PUT /api/sites/:id { brandParent }); absent when the site keeps its own brand. PUT /api/site ignores it. |
brandOverrides? | BrandPart[] | A child site's published overrides: the parts it changed instead of following its parent. Written by publishing the brand draft. |
brandLocks? | BrandPart[] | The parts this site locks for the sites that inherit its brand, as published. Written by publishing the brand draft. |
content? | { keepRevisions?: number } | Editorial settings (Phase 2b). keepRevisions: how many revisions to keep per entity (default DEFAULT_KEEP_REVISIONS). |
integrations? | SiteIntegrations | analytics tags and custom head markup (Phase 2b) |
privacy? | SitePrivacy | consent mode for tags and the returning-visitor cookie |
forms? | SiteForms | Site-wide form settings: the CAPTCHA provider and its keys (Settings → Forms). PUT /api/site accepts forms with site.settings. |
embedAllowedHosts? | string[] | Hosts an Embed block may frame (exact host names). Absent means DEFAULT_EMBED_HOSTS. Set under Settings → Integrations. |
media? | SiteMedia | Settings → Media: the DAM connector (provider, write-only credentials, default folder). See the DAM contract at the end of this file. |
cdn? | SiteCdn | Settings → Cache: the CDN purge hook (provider, write-only credentials, zone id, cache headers). See the CDN contract at the end of this file. |
key? | string | Multisite identity (same row as the settings): see Site. Present on every SiteSettings the server returns. |
domains? | string[] | |
primaryDomain? | `string | null` |
status? | SiteStatus | |
createdAt | string | |
updatedAt | string |
DEFAULT_EMBED_HOSTS
Constant of type readonly string[].
Where an Embed block's <iframe src> may point by default. Every other iframe, and every script, is stripped from Embed and Text (HTML mode) blocks on save and again at render; kept iframes get sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" and loading="lazy".
const DEFAULT_EMBED_HOSTS = [
'www.youtube.com',
'www.youtube-nocookie.com',
'player.vimeo.com',
'www.google.com',
'open.spotify.com',
'embed.podcasts.apple.com',
'www.facebook.com',
'platform.twitter.com',
'www.instagram.com',
]
EMBED_IFRAME_SANDBOX
Constant.
const EMBED_IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-presentation'
ThemeTokens
Interface.
| Member | Type | Notes |
|---|---|---|
colorPrimary | string | CSS color |
colorText | string | |
colorBackground | string | |
fontBody | string | CSS font-family stack |
fontHeading | string | |
radius | string | e.g. "8px" |
maxWidth | string | e.g. "1120px" |
colorSecondary? | string | Extended tokens (Brand area). All optional: an unset color, shadow or mode emits nothing, so blocks fall back as before; the type scale and the space unit default to DEFAULT_THEME. Colors take hex (3, 4, 6 or 8 digits), rgb(), hsl() or a CSS color keyword (other color functions such as oklch() are accepted but get no shades and no contrast check). |
colorAccent? | string | |
colorNeutral? | string | |
colorSuccess? | string | |
colorWarning? | string | |
colorDanger? | string | |
fontSizeBase? | string | The body text size, e.g. "16px" or "1rem" (10 to 32 px, 0.625 to 2 rem or em). |
typeScaleRatio? | string | The type scale ratio as a number in a string, e.g. "1.25" (1 to 2). |
spaceUnit? | string | The base spacing step, e.g. "8px" (2 to 32 px, 0.125 to 2 rem or em). |
shadow? | string | A CSS box-shadow for raised surfaces, e.g. "0 1px 3px rgba(0,0,0,.12)". |
modes? | ThemeModes | Color overrides for dark mode (applied under prefers-color-scheme: dark when enabled is 'auto') and high contrast (prefers-contrast: more). |
custom? | Record<string, string> | Extra tokens as kebab-case names → CSS values, emitted as --sw-<name> on :root (Phase 2b theme import). |
ThemeColorToken
Type alias.
The color tokens: the three core ones and the six extended ones. Dark and high-contrast modes override these.
type ThemeColorToken = 'colorPrimary' | 'colorText' | 'colorBackground' | 'colorSecondary' | 'colorAccent' | 'colorNeutral' | 'colorSuccess' | 'colorWarning' | 'colorDanger'
THEME_COLOR_TOKENS
Constant of type readonly ThemeColorToken[].
const THEME_COLOR_TOKENS = ['colorPrimary', 'colorSecondary', 'colorAccent', 'colorNeutral', 'colorText', 'colorBackground', 'colorSuccess', 'colorWarning', 'colorDanger']
ThemeModeColors
Type alias.
type ThemeModeColors = Partial<Record<ThemeColorToken, string>>
ThemeModes
Interface.
| Member | Type | Notes |
|---|---|---|
dark? | `{ enabled: 'off' | 'auto'; colors: ThemeModeColors }` |
highContrast? | { colors: ThemeModeColors } | Applied for visitors who ask for more contrast (prefers-contrast: more), whenever a color is set. |
DEFAULT_THEME
Constant of type ThemeTokens.
The defaults for the seven core tokens (unchanged since 0.1) and for the type scale and the space unit, which the stylesheet always emits. Every other extended token is unset by default.
const DEFAULT_THEME = {
colorPrimary: '#1d4ed8',
colorText: '#111827',
colorBackground: '#ffffff',
fontBody: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
fontHeading: 'inherit',
radius: '8px',
maxWidth: '1120px',
fontSizeBase: '16px',
typeScaleRatio: '1.25',
spaceUnit: '8px',
}
SiteIdentity
Interface.
The site's logos and favicon (Brand → Identity): media ids in the site's library, published with the theme from the brand draft. The public header shows
logoMediaIdin place of the site name (andlogoOnDarkMediaIdunder prefers-color-scheme: dark when dark mode is on);faviconMediaIdbecomes <link rel="icon">;logoMarkMediaId(the square mark) is stored for later use. The social image stays under SEO (seo.ogImageMediaId).
| Member | Type | Notes |
|---|---|---|
logoMediaId? | `string | null` |
logoMarkMediaId? | `string | null` |
logoOnDarkMediaId? | `string | null` |
faviconMediaId? | `string | null` |
SITE_IDENTITY_KEYS
Constant of type readonly (keyof SiteIdentity)[].
const SITE_IDENTITY_KEYS = ['logoMediaId', 'logoMarkMediaId', 'logoOnDarkMediaId', 'faviconMediaId']
MediaItem
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | multisite: the owning site (the library is per site; file URLs are global) |
filename | string | |
mime | string | |
size | number | |
width | `number | null` |
height | `number | null` |
alt | string | |
title? | string | |
url | string | public URL, e.g. /media/<id>/<filename> |
uploadedBy | string | |
createdAt | string | |
focalPoint? | { x: number; y: number } | Where the subject is, 0..1 from the top-left; cover crops keep this point in frame. Images only. |
variants? | Record<string, string> | Ready-made derivative URLs for images (w480, w768, w1200, w1920, thumb); see mediaUrl(). |
source? | MediaSource | Set when the item references an asset in a DAM instead of a local file (see the DAM contract at the end of this file). |
API contract
ApiError
Interface.
All JSON. Errors: { error: { code: string; message: string; details?: unknown } } with 4xx/5xx.
Auth: httpOnly cookie session "spillway_session". CSRF: same-site cookie + custom header "X-Requested-With: spillway".
| Member | Type | Notes |
|---|---|---|
error | { code: string; message: string; details?: unknown } |
Paginated
Interface.
| Member | Type | Notes |
|---|---|---|
items | T[] | |
total | number | |
page | number | |
limit | number |
SetupStatus
Interface.
GET /api/setup/status. setupTokenRequired: the server has SETUP_TOKEN set, so POST /api/setup needs the X-Setup-Token header.
| Member | Type | Notes |
|---|---|---|
installed | boolean | |
version | string | |
setupTokenRequired? | boolean |
SetupRequest
Interface.
POST /api/setup (header X-Setup-Token when the status says setupTokenRequired)
| Member | Type | Notes |
|---|---|---|
siteName | string | |
email | string | |
password | string | |
name | string |
LoginRequest
Interface.
POST /api/auth/login
| Member | Type | Notes |
|---|---|---|
email | string | |
password | string |
UserPreference
Interface.
Per-user admin preferences kept on the server so they follow the person between browsers (saved list views today). GET /api/me/preferences → UserPreference[]; GET /api/me/preferences/:key → UserPreference (404
not_foundwhen unset); PUT /api/me/preferences/:key { value } → UserPreference (any JSON, 64 KB at most, 413 beyond); DELETE → 204. Keys are 1–120 letters, digits, dots, colons, dashes or underscores (views:content:page). Any signed-in user, own rows only. Audit: user.preference.set, user.preference.delete.
| Member | Type | Notes |
|---|---|---|
key | string | |
value | unknown | |
updatedAt | string |
MeResponse
Interface.
GET /api/auth/me → { user: User | null; permissions: Permission[] }
| Member | Type | Notes |
|---|---|---|
user | `User | null` |
permissions | Permission[] | Effective permissions on the request's site (global roles ∪ site roles, plus the ambient subset granted by section roles). |
siteId? | string | Multisite: the site this request was scoped to and the sections the user holds a role in there (with the permissions each grants). |
sections? | MeSection[] |
ContentTypeInput
Type alias.
GET /api/types → ContentType[]; POST /api/types (ContentTypeInput); GET/PUT/DELETE /api/types/:key
type ContentTypeInput = Omit<ContentType, 'createdAt' | 'updatedAt'>
EntityInput
Interface.
GET /api/entities?type=&status=&q=&page=&limit=&sort=updatedAt:desc → Paginated<Entity> POST /api/entities (EntityInput) → Entity (creates draft revision 1) GET /api/entities/:id → Entity PUT /api/entities/:id (Partial<EntityInput> & { note?: string }) → Entity (new revision) DELETE /api/entities/:id → 204 POST /api/entities/:id/publish → Entity POST /api/entities/:id/unpublish → Entity POST /api/entities/:id/submit → Entity (status: review) GET /api/entities/:id/revisions → Revision[] POST /api/entities/:id/revisions/:revisionId/restore → Entity (new revision copied from old) GET /api/entities/by-path?path=/about → Entity (published only unless authenticated with content.view_drafts)
| Member | Type | Notes |
|---|---|---|
type | string | |
title | string | |
slug? | `string | null` |
locale? | string | |
data | Record<string, unknown> | |
sectionId? | `string | null` |
SPILLWAY_VERSION
Constant.
GET /api/media?q=&page=&limit= → Paginated<MediaItem> POST /api/media (multipart/form-data: file, alt?) → MediaItem PUT /api/media/:id ({ alt, title }) → MediaItem DELETE /api/media/:id → 204 GET /media/:id/:filename → the file (public)
GET /api/site → SiteSettings; PUT /api/site (Partial<SiteSettings>) → SiteSettings
GET /api/users → User[]; POST /api/users ({ email, name, password, roles }) → User PUT /api/users/:id ({ name?, roles?, password? }) → User; DELETE /api/users/:id → 204
Public rendering: GET /<path> published page for an entity whose path matches GET / site home page (SiteSettings.homePageId) or a default welcome page GET /_preview/:entityId latest revision, requires session with content.view_drafts Response headers: Cache-Tag: entity:<id>,type:<type>,site:<id> | X-Spillway-Cache: HIT|MISS Admin SPA: GET /admin, /admin/* built Vite app from packages/admin Health: GET /healthz → { ok: true, version }
const SPILLWAY_VERSION = '0.1.0'
SESSION_COOKIE
Constant.
const SESSION_COOKIE = 'spillway_session'
CSRF_HEADER
Constant.
const CSRF_HEADER = 'x-requested-with'
CSRF_VALUE
Constant.
const CSRF_VALUE = 'spillway'
CONTENT_TYPE_KEY_RE
Constant.
const CONTENT_TYPE_KEY_RE = /^[a-z][a-z0-9_]{1,62}$/
FIELD_KEY_RE
Constant.
const FIELD_KEY_RE = /^[a-z][a-zA-Z0-9_]{0,62}$/
slugify()
Function.
function slugify(input: string): string
resolvePath()
Function.
Resolve a ContentType.pathPattern for an entity.
function resolvePath(pattern: string, entity: { slug: string | null; id: string; type: string; createdAt: string }): string
BUILTIN_TYPES
Constant of type ContentTypeInput[].
The two content types every site starts with.
const BUILTIN_TYPES = [
{
key: 'page',
label: 'Page',
labelPlural: 'Pages',
description: 'A page built visually from blocks.',
icon: 'layout',
hasPath: true,
pathPattern: '/{slug}',
titleField: 'title',
fields: [
{ key: 'title', label: 'Title', type: 'text', required: true, aiHint: 'Page title, under 60 characters.' },
{ key: 'layout', label: 'Layout', type: 'layout', aiHint: 'The page content as blocks.' },
{ key: 'seoTitle', label: 'SEO title', type: 'text', aiHint: 'Search result title, under 60 characters.' },
{ key: 'seoDescription', label: 'SEO des
// …
Redirects and 404 log
Redirect
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | multisite: the owning site |
from | string | path, e.g. "/old-page" (exact match, case-insensitive, trailing slash ignored) |
to | string | path or absolute URL |
status | `301 | 302` |
enabled | boolean | |
hits | number | |
createdAt | string | |
updatedAt | string |
Collections (saved queries, "Views" for marketers)
CollectionOp
Type alias.
type CollectionOp = 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'exists' | 'has_term'
CollectionFilter
Interface.
| Member | Type | Notes |
|---|---|---|
field | string | |
op | CollectionOp | |
value? | unknown |
Collection
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | multisite: the owning site (keys are unique per site) |
key | string | CONTENT_TYPE_KEY_RE |
label | string | |
type | string | ContentType.key |
filters | CollectionFilter[] | |
sort | `{ field: string; dir: 'asc' | 'desc' }` |
limit | number | 1..100 |
expand? | `'occurrences' | null` |
createdAt | string | |
updatedAt | string |
CollectionItem
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | path is the public path (locale prefix included for non-default locales). |
type | string | |
title | string | |
path | `string | null` |
locale? | string | |
summary? | string | |
imageUrl? | string | |
date? | string | |
data? | Record<string, unknown> | |
occurrence? | { start: string; end?: string; allDay?: boolean } | Set when the collection expands occurrences: the occurrence this item stands for. |
Site search
SearchResult
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
type | string | |
title | string | |
path | `string | null` |
snippet | string | |
score | number | |
updatedAt | string | |
locale? | string |
SearchFacets
Interface.
Facet counts over the matching set: by content type, and by taxonomy term label (Meilisearch only; the SQL providers return
{}for terms).
| Member | Type | Notes |
|---|---|---|
type | Record<string, number> | |
terms | Record<string, number> |
SearchResponse
Interface (extends Paginated<SearchResult>).
| Member | Type | Notes |
|---|---|---|
facets | SearchFacets |
Forms and lead capture
FormFieldType
Type alias.
type FormFieldType = 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'checkbox' | 'number' | 'hidden' | 'file'
FormConditionOp
Type alias.
Conditional logic: the field is shown (and validated) only while another field matches the rule.
type FormConditionOp = 'equals' | 'notEquals' | 'contains' | 'isEmpty' | 'isNotEmpty' | 'gt' | 'lt'
FormCondition
Interface.
| Member | Type | Notes |
|---|---|---|
field | string | |
op | FormConditionOp | |
value? | `string | number |
FORM_CONDITION_OPS
Constant of type FormConditionOp[].
const FORM_CONDITION_OPS = ['equals', 'notEquals', 'contains', 'isEmpty', 'isNotEmpty', 'gt', 'lt']
FormField
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
type | FormFieldType | |
required? | boolean | |
options? | string[] | |
placeholder? | string | |
help? | string | |
visibleWhen? | FormCondition | Shown only while the rule matches; hidden fields are never required and their values are dropped on submit. |
accept? | string[] | file fields: accepted extensions (pdf) or MIME types (image/*, application/pdf); empty means any type. |
maxSize? | number | file fields: largest accepted file in bytes (default FORM_UPLOAD_DEFAULT_MAX_BYTES, never above FORM_UPLOAD_MAX_BYTES). |
maxCount? | number | file fields: how many files the field takes (default 1, at most FORM_UPLOAD_MAX_COUNT). |
FormStep
Interface.
A multi-step form: each step lists the keys of the fields it holds; a key in no step is rendered on the last step.
| Member | Type | Notes |
|---|---|---|
title | string | |
fields | string[] |
FORM_UPLOAD_MAX_BYTES
Constant.
const FORM_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
FORM_UPLOAD_DEFAULT_MAX_BYTES
Constant.
const FORM_UPLOAD_DEFAULT_MAX_BYTES = 10 * 1024 * 1024
FORM_UPLOAD_MAX_COUNT
Constant.
const FORM_UPLOAD_MAX_COUNT = 10
FormConnector
Type alias.
type FormConnector = | { kind: 'webhook'; url: string; secret?: string } // POST JSON, header X-Spillway-Signature: sha256 hmac of body
| { kind: 'hubspot'; portalId: string; formGuid: string; fieldMap: Record<string, string> } // Forms API v3 submit, fieldMap: spillway key → hubspot field name
| { kind: 'email'; to: string[] }
FormDefinition
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | multisite: the owning site (keys are unique per site) |
key | string | CONTENT_TYPE_KEY_RE |
label | string | |
fields | FormField[] | |
steps? | FormStep[] | Multi-step forms (optional): the Form block renders a stepper, validates each step on the server before moving on; absent means one step. |
submitLabel | string | |
successMessage | string | |
connectors | FormConnector[] | |
captureUtm | boolean | store utm_* params, referrer and landing page with the submission |
createdAt | string | |
updatedAt | string |
FormDelivery
Interface.
| Member | Type | Notes |
|---|---|---|
connector | FormConnector['kind'] | |
ok | boolean | |
error? | string | |
at | string |
FormSubmissionFile
Interface.
A file a visitor attached to a
filefield; the bytes live in the private form-uploads store, never under /media.
| Member | Type | Notes |
|---|---|---|
id | string | |
field | string | |
filename | string | |
mime | string | |
size | number |
FormSubmission
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
formId | string | |
siteId? | string | |
data | Record<string, unknown> | |
meta | { ip?: string; userAgent?: string; referrer?: string; page?: string; utm: Record<string, string>; consent?: boolean; captcha?: FormCaptchaProvider } | |
status | `'received' | 'delivered' |
deliveries | FormDelivery[] | |
files? | FormSubmissionFile[] | Uploaded files (data[field] holds the file name(s)); downloadable through GET /api/forms/:id/submissions/:sid/files/:fid. |
createdAt | string |
FormFeedbackSummary
Interface.
GET /api/forms/:id/feedback?days=30 (forms.view_submissions): the yes/no tally of a feedback form such as the docs kit's
docs_feedback(fieldshelpful= yes|no,comment, hiddenpageandversion) over the lastdaysdays (1–365, default 30) and the last five comments. The Dashboard shows it as the "Docs feedback" card when a form with the keydocs_feedbackexists on the site.
| Member | Type | Notes |
|---|---|---|
formId | string | |
formKey | string | |
days | number | |
total | number | |
yes | number | |
no | number | |
comments | `{ id: string; createdAt: string; helpful: 'yes' | 'no' |
FormCaptchaProvider
Type alias.
CAPTCHA providers a site can verify submissions with (Settings → Forms). 'none' keeps the honeypot and the time-to-submit check only.
type FormCaptchaProvider = 'none' | 'turnstile' | 'hcaptcha'
FORM_CAPTCHA_PROVIDERS
Constant of type FormCaptchaProvider[].
const FORM_CAPTCHA_PROVIDERS = ['none', 'turnstile', 'hcaptcha']
SiteForms
Interface.
SiteSettings.forms: site-wide form settings. The secret is write-only on the API (read back as
********).
| Member | Type | Notes |
|---|---|---|
captcha? | { provider: FormCaptchaProvider; siteKey?: string; secret?: string } |
Editorial review
EntityComment
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
entityId | string | |
fieldKey | `string | null` |
authorId | string | |
authorName | string | |
body | string | |
resolvedAt | `string | null` |
createdAt | string | |
revisionId? | `string | null` |
ReviewQueueItem
Interface.
| Member | Type | Notes |
|---|---|---|
entity | Entity | |
authorName | string | |
waitingSince | string | |
commentCount | number |
EntityCheck
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
level | `'error' | 'warning'` |
message | string | |
field? | string | |
blockId? | string |
AI
AiProvider
Type alias.
type AiProvider = 'anthropic' | 'openai' | 'google' | 'xai' | 'openai_compatible'
AiTask
Type alias.
type AiTask = 'page' | 'copy' | 'type' | 'translate' | 'alt' | 'summary' | 'import'
AiProviderSettings
Interface.
| Member | Type | Notes |
|---|---|---|
model | string | |
baseUrl? | string | |
hasKey | boolean |
AiSettings
Interface.
| Member | Type | Notes |
|---|---|---|
providers | Partial<Record<AiProvider, AiProviderSettings>> | |
tasks | Record<AiTask, { provider: AiProvider; model?: string }> | |
monthlyTokenCap | `number | null` |
enabled | boolean |
AiSettingsInput
Interface (extends Partial<Omit<AiSettings, 'providers'>>).
| Member | Type | Notes |
|---|---|---|
providers? | `Partial<Record<AiProvider, { model?: string; baseUrl?: string; apiKey?: string | null }>>` |
BrandProfile
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | |
voice | string | how we sound, 2 to 5 sentences |
tone | string[] | e.g. ["warm", "direct", "plain-spoken"] |
audience | string | |
terminology | string[] | preferred terms, "use X not Y" |
bannedWords | string[] | |
boilerplate | string | the one-paragraph description of the organization |
examples | string[] | short samples of on-brand copy |
updatedAt | string |
AiAssistAction
Type alias.
type AiAssistAction = 'write' | 'rewrite' | 'shorten' | 'expand' | 'tone' | 'fix' | 'translate' | 'summarize'
AiAssistRequest
Interface.
| Member | Type | Notes |
|---|---|---|
action | AiAssistAction | |
text? | string | |
instruction? | string | |
locale? | string | for translate |
field? | { key: string; label: string; aiHint?: string; type?: FieldType } | |
context? | { title?: string; typeLabel?: string } |
AiPageRequest
Interface.
| Member | Type | Notes |
|---|---|---|
brief | string | |
typeKey? | string | |
goal? | string | |
audience? | string |
AiPageResponse
Interface.
| Member | Type | Notes |
|---|---|---|
title | string | |
layout | PuckData | |
notes | string[] | |
usage | AiUsage |
AiUsage
Interface.
| Member | Type | Notes |
|---|---|---|
provider | AiProvider | |
model | string | |
inputTokens | number | |
outputTokens | number | |
ms | number |
AiUsageReport
Interface.
| Member | Type | Notes |
|---|---|---|
month | string | |
calls | number | |
inputTokens | number | |
outputTokens | number | |
byTask | Record<string, { calls: number; tokens: number }> | |
byUser | Record<string, { calls: number; tokens: number }> |
Phase 3 contract
ApiToken
Interface.
API tokens: long-lived bearer tokens for agents and integrations, scoped to a user and a permission subset.
| Member | Type | Notes |
|---|---|---|
id | string | |
userId | string | |
label | string | |
prefix | string | |
permissions | Permission[] | |
lastUsedAt | `string | null` |
expiresAt | `string | null` |
createdAt | string |
ApiTokenCreateRequest
Interface.
GET /api/tokens (own tokens; users.manage sees all), POST /api/tokens ({ label, permissions?, expiresInDays? }) → { token: ApiToken; secret: string } (secret shown once; stored as sha256), DELETE /api/tokens/:id.
Bearer auth:
Authorization: Bearer spw_<prefix>_<secret>is accepted on /api/* and /mcp; the effective permissions are the intersection of the token's permissions and the user's roles; CSRF is not required for bearer requests; every bearer request is audited with the token id.MCP: POST /mcp (Streamable HTTP, JSON-RPC 2.0) and GET /mcp for the SSE stream if implemented. Auth: bearer token only. The server exposes:
tools: list_content_types, get_content_type, list_entities (type, status, q, page), get_entity, create_entity (always a draft), update_entity (new revision), submit_for_review, publish_entity (only with content.publish; refuses when checks fail unless force), get_checks, list_revisions, search_site, list_collections, get_collection_items, list_media, get_site_settings, draft_page_from_brief (returns a draft entity id using the AI layer when enabled), assist_text, list_review_queue, add_comment
resources: spillway://types, spillway://types/{key}, spillway://entities/{id}, spillway://site
prompts: "write-page" (brief → the page tool sequence), "review-entity"
Every tool result is JSON; errors carry the API's error code; the AI never publishes; rate limit 120 calls/min/token.
| Member | Type | Notes |
|---|---|---|
label | string | |
permissions? | Permission[] | |
expiresInDays? | `number | null` |
ApiTokenCreateResponse
Interface.
| Member | Type | Notes |
|---|---|---|
token | ApiToken | |
secret | string |
API_TOKEN_PREFIX
Constant.
const API_TOKEN_PREFIX = 'spw'
MCP_RATE_LIMIT_PER_MINUTE
Constant.
const MCP_RATE_LIMIT_PER_MINUTE = 120
Module system contract
ModuleTier
Type alias.
type ModuleTier = 'trusted' | 'sandboxed'
Capability
Type alias.
type Capability = 'entity.read' | 'entity.write' | 'media.read' | 'site.read' | `http:${string}` | 'hooks' | 'blocks' | 'fields' | 'admin' | 'ai.tools'
ModuleHook
Type alias.
type ModuleHook = 'entity.beforeSave' | 'entity.afterSave' | 'entity.afterPublish' | 'form.afterSubmit' | 'page.beforeRender'
MODULE_HOOKS
Constant of type ModuleHook[].
const MODULE_HOOKS = ['entity.beforeSave', 'entity.afterSave', 'entity.afterPublish', 'form.afterSubmit', 'page.beforeRender']
ModuleManifest
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | ^[a-z][a-z0-9-]*$ (optionally scoped: @scope/name) |
version | string | semver |
tier | ModuleTier | |
description | string | |
spillway | string | semver range of core this module supports, e.g. "^0.1.0" |
capabilities | Capability[] | |
hooks? | ModuleHook[] | |
main | string | entry: .js for trusted, .wasm for sandboxed; relative to the module directory |
homepage? | string | |
license? | string | |
aiTools? | { name: string; description: string; inputSchema?: Record<string, unknown> }[] | Sandboxed modules may expose AI tools; the host calls handle({ hook: 'ai.tool', payload: { name, input } }). Needs capability 'ai.tools'. |
blocks? | ModuleBlockDef[] | Blocks the module offers to the page builder (needs capability 'blocks'). A page stores one as a ModuleBlock node with { module, blockKey, props }; the server asks the module for HTML at render time (docs/modules.md, "Blocks in the page builder"). |
ModuleBlockFieldType
Type alias.
The field kinds a module block may declare: the builder renders them with its generic field widgets.
arrayitems hold the other kinds.
type ModuleBlockFieldType = 'text' | 'textarea' | 'number' | 'select' | 'radio' | 'boolean' | 'media' | 'link' | 'array'
ModuleBlockField
Interface.
| Member | Type | Notes |
|---|---|---|
type | ModuleBlockFieldType | |
label? | string | |
placeholder? | string | |
options? | `{ label: string; value: string | number |
min? | number | number |
max? | number | |
step? | number | |
arrayFields? | Record<string, ModuleBlockField> | array: the fields of one item (every kind but array) |
maxItems? | number | array: a cap on the number of items (the host caps every array at 100) |
ModuleBlockDef
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | ^[a-z][a-z0-9-]{0,63}$, unique within the module |
label | string | |
category? | string | the builder's sidebar group label; default "Modules" |
description? | string | |
fields | Record<string, ModuleBlockField> | |
defaults? | Record<string, unknown> | |
preview? | 'html' | 'html' (the default and only value today): the builder shows the server-rendered HTML as the block's preview. |
styles? | StyledCoreBlock | Block styles (see "Block styles"): styles names a core block whose style set this block uses (one of BLOCK_STYLED_CORE_BLOCKS, e.g. "Cards"), or styleSlots declares the block's own slots (at most 8, each with 1 to 20 kebab-case values and an optional default, the first value otherwise). Not both. Either reserves the prop names style and styleValues: the render receives the style key and the slot values there, so no field may use those names. |
styleSlots? | Record<string, ModuleStyleSlot> |
ModuleStyleSlot
Interface.
One slot of a module block's own style set.
| Member | Type | Notes |
|---|---|---|
label? | string | |
options | BlockStyleSlotOption[] | |
default? | string |
BuilderModuleBlock
Interface (extends ModuleBlockDef).
GET /api/builder/config → BuilderConfig: the blocks of every loaded module, and the published block styles with each block's slots (see "Block styles"), for the page builder.
| Member | Type | Notes |
|---|---|---|
module | string | |
tier | ModuleTier |
BuilderConfig
Interface.
| Member | Type | Notes |
|---|---|---|
blocks | BuilderModuleBlock[] | |
safeMode | boolean | |
styles | BuilderBlockStyle[] | |
styleSlots | Record<string, BlockStyleSlot[]> |
BlockRenderResult
Interface.
POST /api/render/block ({ module, key, props, path?, locale? }) → BlockRenderResult: the HTML the ModuleBlock would render on a page.
| Member | Type | Notes |
|---|---|---|
html | string | |
placeholder | boolean | |
label | string | |
reason? | string |
InstalledModule
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
name | string | |
version | string | |
tier | ModuleTier | |
enabled | boolean | |
manifest | ModuleManifest | |
source | `'local' | 'registry' |
installedAt | string | |
lastError | `string | null` |
settings | Record<string, unknown> |
ModulesStatus
Interface.
GET /api/modules/status
| Member | Type | Notes |
|---|---|---|
safeMode | boolean | |
restartPending | boolean | |
restartReason | `string | null` |
bootId | string | |
coreVersion | string | |
modulesDir | string |
RegistryEntry
Interface.
GET /api/modules/registry?q= → { items: RegistryEntry[] }
| Member | Type | Notes |
|---|---|---|
name | string | |
description | string | |
latest | string | |
versions | string[] | |
tier | ModuleTier | |
homepage? | string | |
license? | string |
Phase 2b editorial contract
EntitySchedule
Interface.
Scheduling
| Member | Type | Notes |
|---|---|---|
publishAt | `string | null` |
unpublishAt | `string | null` |
ScheduledItem
Interface.
Entity gains
publishAt,unpublishAtandlastScheduleError; PUT /api/entities/:id/schedule ({ publishAt?, unpublishAt? }) (content.publish); DELETE clears.Scheduled items show in GET /api/review/queue?view=scheduled (→ ScheduledItem[]) and in the list filter status=scheduled (status stays draft/review
until the time;
scheduledis a virtual filter = publishAt set and not yet published, or unpublishAt set on a published entity).A ticker runs every 30 s in-process (and
spillway run-scheduledfrom the CLI for cron hosts): publishes via publishEntity with force=false(checks must pass; failures are logged, audited and the item is left scheduled with lastScheduleError) and unpublishes to archived. Each run is audited.
| Member | Type | Notes |
|---|---|---|
entity | Entity | |
authorName | string | |
publishAt | `string | null` |
unpublishAt | `string | null` |
lastScheduleError | `string | null` |
SCHEDULE_TICK_MS
Constant.
const SCHEDULE_TICK_MS = 30_000
EntityLock
Interface.
Content locking
| Member | Type | Notes |
|---|---|---|
entityId | string | |
userId | string | |
userName | string | |
acquiredAt | string | |
heartbeatAt | string | |
expiresAt | string |
LOCK_TTL_MS
Constant.
POST /api/entities/:id/lock (acquire or heartbeat; 409
lock_heldwith the current lock in details when held by someone else),DELETE /api/entities/:id/lock (release; content.edit_any may break another user's lock), GET returns the lock or null.
Locks expire LOCK_TTL_MS (15 min) after the last heartbeat. PUT /api/entities/:id is refused with 409 lock_held when another user holds a
live lock unless
?breakLock=1with content.edit_any.
const LOCK_TTL_MS = 15 * 60 * 1000
LOCK_HEARTBEAT_MS
Constant.
const LOCK_HEARTBEAT_MS = 60 * 1000
DiffKind
Type alias.
Revision diff and pruning
GET /api/entities/:id/revisions/:a/diff/:b → RevisionDiff
type DiffKind = 'added' | 'removed' | 'changed' | 'same'
InlineDiffPart
Interface.
| Member | Type | Notes |
|---|---|---|
op | `'eq' | 'ins' |
text | string |
FieldDiff
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
kind | DiffKind | |
before | unknown | |
after | unknown | |
inline? | InlineDiffPart[] |
LayoutBlockDiff
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
type | string | |
change | `'added' | 'removed' |
RevisionDiff
Interface.
| Member | Type | Notes |
|---|---|---|
a | { id: string; number: number; createdAt: string } | |
b | { id: string; number: number; createdAt: string } | |
fields | FieldDiff[] | |
layout | { added: number; removed: number; changed: number; blocks: LayoutBlockDiff[] } |
DEFAULT_KEEP_REVISIONS
Constant.
SiteSettings.content.keepRevisions (default 50): on each save keep the published revision, the current revision, the latest N and any revision
referenced by a comment; prune the rest.
spillway prune-revisionsCLI prunes every entity.
const DEFAULT_KEEP_REVISIONS = 50
NotificationEvent
Type alias.
Notifications
type NotificationEvent = 'review.submitted' | 'review.rejected' | 'entity.published' | 'entity.unpublished' | 'entity.deleted' | 'comment.added' | 'schedule.failed' | 'form.submitted' | 'workstream.merged' | 'translation.ready' | 'backup.failed'
NOTIFICATION_EVENTS
Constant of type NotificationEvent[].
backup.failed: a scheduled or manual backup run failed (jobs/backup.ts); the payload carrieserrorand the run's location innote.
const NOTIFICATION_EVENTS = ['review.submitted', 'review.rejected', 'entity.published', 'entity.unpublished', 'entity.deleted', 'comment.added', 'schedule.failed', 'form.submitted', 'workstream.merged', 'translation.ready', 'backup.failed']
NotificationChannel
Type alias.
type NotificationChannel = | { kind: 'email'; to: 'author' | 'reviewers' | 'custom'; emails?: string[] }
| { kind: 'webhook'; url: string; secret?: string }
| { kind: 'slack'; webhookUrl: string }
NotificationRule
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
event | NotificationEvent | |
channel | NotificationChannel | |
enabled | boolean | |
filter? | { types?: string[] } | |
createdAt | string | |
updatedAt | string |
NotificationRuleInput
Type alias.
type NotificationRuleInput = Omit<NotificationRule, 'id' | 'createdAt' | 'updatedAt'>
OutboxStatus
Type alias.
type OutboxStatus = 'pending' | 'delivered' | 'failed'
OutboxItem
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
event | NotificationEvent | |
ruleId | `string | null` |
channel | NotificationChannel['kind'] | |
target | string | who or where: the recipient list, the webhook URL or "slack" |
subject | string | one line describing the event, e.g. "Review requested: About us" |
status | OutboxStatus | |
attempts | number | |
lastError | `string | null` |
nextAttemptAt | `string | null` |
createdAt | string | |
deliveredAt | `string | null` |
OUTBOX_MAX_ATTEMPTS
Constant.
GET/POST/PUT/DELETE /api/notifications/rules (site.settings); POST /api/notifications/rules/:id/test sends a sample event through the rule now;
GET /api/notifications/outbox?page= (site.settings) → Paginated<OutboxItem>, the last OUTBOX_KEEP deliveries with status.
Events are queued in an
outboxtable and delivered by the same ticker with OUTBOX_MAX_ATTEMPTS attempts (30 s, then 2 min, then 10 min apart);email via the existing SMTP connector (SMTP_URL) with plain-text templates; Slack via incoming webhook JSON; webhook with the existing HMAC helper
(X-Spillway-Signature over the JSON body, X-Spillway-Event: <event>). Users also get per-user email preferences (
notifyOnReview,notifyOnCommentbooleans on their profile; PUT /api/auth/me ({ name?, notifyOnReview?, notifyOnComment? }) → MeResponse).
const OUTBOX_MAX_ATTEMPTS = 3
OUTBOX_KEEP
Constant.
const OUTBOX_KEEP = 500
Taxonomy
Vocabulary
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | CONTENT_TYPE_KEY_RE, e.g. "topics", "departments" |
label | string | |
hierarchical | boolean | when false the admin keeps every term at the root |
description? | string | |
termPathPattern? | string | e.g. "/topics/{slug}": every term gets a public listing page at that path; unset means no term pages. Tokens: {slug}, {id}, {path} (ancestor slugs joined by "/"). |
createdAt | string | |
updatedAt | string |
Term
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
vocabularyId | string | |
parentId | `string | null` |
label | string | |
slug | string | unique among siblings |
description? | string | |
weight | number | sort order among siblings |
path | `string | null` |
depth | number | 0 for root terms |
data | Record<string, unknown> | |
createdAt | string | |
updatedAt | string |
VocabularyInput
Type alias.
type VocabularyInput = Omit<Vocabulary, 'id' | 'createdAt' | 'updatedAt'>
TermInput
Type alias.
type TermInput = { label: string; slug?: string; parentId?: string | null; description?: string; weight?: number; data?: Record<string, unknown> }
TermMove
Interface.
One row of POST /api/vocabularies/:id/terms/reorder.
| Member | Type | Notes |
|---|---|---|
id | string | |
parentId | `string | null` |
weight | number |
TermNode
Interface (extends Term).
Nested shape returned by GET /api/vocabularies/:id/terms (ordered by weight, then label).
| Member | Type | Notes |
|---|---|---|
children | TermNode[] |
Image derivatives
MediaFit
Type alias.
type MediaFit = 'cover' | 'contain' | 'inside'
MediaFormat
Type alias.
type MediaFormat = 'webp' | 'avif' | 'jpeg' | 'png'
MediaUrlOptions
Interface.
| Member | Type | Notes |
|---|---|---|
w? | number | |
h? | number | |
fit? | MediaFit | |
fmt? | MediaFormat | |
q? | number |
MEDIA_SRCSET_WIDTHS
Constant.
Width breakpoints emitted in
srcsetby the Image block, the Hero background and media fields.
const MEDIA_SRCSET_WIDTHS = [480, 768, 1200, 1920] as const
MEDIA_MAX_DIMENSION
Constant.
const MEDIA_MAX_DIMENSION = 4000
MEDIA_FORMATS
Constant of type MediaFormat[].
const MEDIA_FORMATS = ['webp', 'avif', 'jpeg', 'png']
MEDIA_FITS
Constant of type MediaFit[].
const MEDIA_FITS = ['cover', 'contain', 'inside']
MediaUrlSource
Type alias.
The URL of a derivative of an image: GET /media/:id/:filename?w=&h=&fit=&fmt=&q=. The server generates it on demand with sharp (cropped around the focal point for
cover), caches it on disk and serves it immutably; unknown parameters are ignored and dimensions are capped at MEDIA_MAX_DIMENSION. Non-image files ignore the options. Accepts a MediaItem or a plain /media/... URL.What
mediaUrl()reads: the URL, and for DAM-backed items the source plus the focal point and dimensions the provider's crop syntax needs.
type MediaUrlSource = Pick<MediaItem, 'url'> & Partial<Pick<MediaItem, 'source' | 'focalPoint' | 'width' | 'height'>>
mediaUrl()
Function.
function mediaUrl(item: MediaUrlSource | string, opts: MediaUrlOptions = {}): string
mediaSrcSet()
Function.
srcsettext for the given widths (defaults to MEDIA_SRCSET_WIDTHS, skipping widths wider than the source whenmaxWidthis known).
function mediaSrcSet(item: MediaUrlSource | string, opts: MediaUrlOptions & { widths?: readonly number[]; maxWidth?: number | null } = {}): string
parseMediaUrl()
Function.
Media id and filename from a /media/<id>/<filename> URL, or null for anything else.
function parseMediaUrl(url: string): { id: string; filename: string } | null
Rich text
RICHTEXT_ALLOWED_TAGS
Constant.
richtextvalues are sanitised HTML strings produced by the Tiptap editor: h2–h4, p, br, strong/b, em/i, u, s, a, ul/ol/li, blockquote, code/pre, table/thead/tbody/tr/th/td, img, hr. Internal links are stored as<a href="/path" data-entity-id="…">; at render the server rewrites href to the entity's current path and drops the link (keeping its text) when the entity is not published. Media embeds are stored as<img data-media-id="…" src="/media/…" alt="…">and re-rendered through the derivative pipeline. The server-side sanitiser (tag and attribute allow-list; no scripts, event handlers, styles or javascript: URLs) runs on save and again at render. POST /api/richtext/sanitize ({ html }) → { html } (any user) for the admin preview.
const RICHTEXT_ALLOWED_TAGS = ['h2', 'h3', 'h4', 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'hr', 'figure', 'figcaption', 'span', 'sub', 'sup'] as const
Shared blocks (reusable component library)
SharedBlock
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | CONTENT_TYPE_KEY_RE |
label | string | |
block | PuckComponentData | |
createdAt | string | |
updatedAt | string |
SharedBlockInput
Type alias.
type SharedBlockInput = Pick<SharedBlock, 'key' | 'label' | 'block'>
Date ranges and recurrence
OCCURRENCE_HORIZON_MONTHS
Constant.
const OCCURRENCE_HORIZON_MONTHS = 24
OCCURRENCE_FIELD
Constant.
const OCCURRENCE_FIELD = 'occurrence.start'
Exposed filters and pagination for visitors
ExposedFilterKind
Type alias.
type ExposedFilterKind = 'search' | 'taxonomy' | 'select' | 'daterange'
ExposedFilter
Interface.
| Member | Type | Notes |
|---|---|---|
field | string | |
label | string | |
kind | ExposedFilterKind |
LISTING_PAGE_TTL_MS
Constant.
The Collection block gains props { filters: ExposedFilter[]; pageSize: number; showPager: boolean; showResultCount: boolean }. The server renders the filter form (GET, no JavaScript needed) and reads ?q=&<field>=&from=&to=&page= from the page URL:
searchmatches title and summary-like fields (contains),selectandtaxonomymatch one value (taxonomy values are term ids; options come from the vocabulary when it exists, otherwise from the distinct stored values),daterangeapplies from/to tooccurrence.startfor daterange fields and to the date field otherwise. Other query parameters are preserved in the form and pager links. A page whose render depended on the query string is cached per query string for LISTING_PAGE_TTL_MS; other pages keep their path-keyed, tag-evicted cache entry. TheCalendarblock (props { collectionId, view: 'month' | 'list', monthsAhead }) renders a month grid (or a date list) with links to each occurrence and a link to the collection's .ics feed; ?month=YYYY-MM picks the first month shown. GET /feeds/<collectionKey>.ics → text/calendar (VCALENDAR; one VEVENT per entity with RRULE/EXDATE for recurring daterange fields). GET /feeds/<collectionKey>.rss → RSS 2.0 for any collection (title, link, description, pubDate).
const LISTING_PAGE_TTL_MS = 60_000
FILTER_PARAM
Constant.
const FILTER_PARAM = { search: 'q', from: 'from', to: 'to', page: 'page' } as const
Site alerts
SiteAlertLevel
Type alias.
type SiteAlertLevel = 'info' | 'warning' | 'emergency'
SiteAlert
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | |
level | SiteAlertLevel | |
title | string | |
body | string | |
href? | string | |
startsAt | `string | null` |
endsAt | `string | null` |
dismissible | boolean | |
enabled | boolean | |
createdAt | string | |
updatedAt | string |
SiteAlertInput
Type alias.
type SiteAlertInput = Omit<SiteAlert, 'id' | 'createdAt' | 'updatedAt'>
ALERT_LEVELS
Constant of type SiteAlertLevel[].
GET /api/alerts → SiteAlert[]; POST /api/alerts (SiteAlertInput); PUT /api/alerts/:id (Partial<SiteAlertInput>); DELETE /api/alerts/:id. Permission: content.publish. Active alerts (enabled, inside their startsAt/endsAt window) render in the shell above the header. Pages stay cacheable: the shell carries an empty mount plus a 1 KB inline script that fetches GET /_alerts (public JSON { alerts: SiteAlert[] }, Cache-Control: no-store, honours the dismiss cookies) and injects the banners; an emergency alert spans the full width. Dismissing sets cookie
sw_alert_<id>=1for ALERT_DISMISS_DAYS when the alert is dismissible.
const ALERT_LEVELS = ['info', 'warning', 'emergency']
ALERT_DISMISS_DAYS
Constant.
const ALERT_DISMISS_DAYS = 7
ALERT_COOKIE_PREFIX
Constant.
const ALERT_COOKIE_PREFIX = 'sw_alert_'
Analytics integrations
SiteIntegrations
Interface.
| Member | Type | Notes |
|---|---|---|
gtmId? | string | GTM-XXXXXXX: the shell injects the container snippet in <head> and the noscript iframe after <body> |
ga4Id? | string | G-XXXXXXXXXX: gtag.js; skipped when gtmId is set (GTM should load GA) |
plausibleDomain? | string | |
customHeadHtml? | string | Raw markup for <head>, limited to <script>, <meta> and <link> tags (site.settings only; other tags are stripped). |
Kits (config as code)
KitVocabulary
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
hierarchical? | boolean | |
description? | string | |
termPathPattern? | string | |
terms? | KitTerm[] |
KitTerm
Interface.
| Member | Type | Notes |
|---|---|---|
label | string | |
slug? | string | |
description? | string | |
children? | KitTerm[] |
KitPage
Interface.
| Member | Type | Notes |
|---|---|---|
title | string | |
slug | string | |
layout | PuckData | |
status | `'published' | 'draft'` |
seoDescription? | string | |
home? | boolean |
KitMenu
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
items | { label: string; href: string; children?: { label: string; href: string }[] }[] |
KitEntity
Type alias.
Sample content: EntityInput plus
statusand optionalrefso other items can point at it ({ "$ref": "<ref>" }in reference fields); taxonomy values are term labels; media fields (and any string in the data) may name a kit media item as$media:<key>(its id) or$media:<key>:url(its public URL).
type KitEntity = EntityInput & { status?: 'published' | 'draft'; ref?: string }
KitMedia
Interface.
A media file the kit seeds:
url(https only, fetched through the SSRF guard) orbase64(inline bytes), at most 2 MB each. The installer stores it once (its id is derived from the kit and media key, so re-installing skips it).
| Member | Type | Notes |
|---|---|---|
key | string | |
filename | string | |
url? | string | |
base64? | string | |
alt | string | |
title? | string |
KitRequirements
Interface.
What a kit needs before it installs: other kits (by key, optional semver range), modules (by registry name, optional range) and a range of core versions. The installer resolves kits recursively (bundled kits first, then the registry the site is pointed at), installs them dependencies-first, skips what is already installed at a satisfying version, and installs missing modules from the registry (sandboxed only unless SPILLWAY_TRUSTED_MODULE_INSTALL=1). A cycle, a kit nobody has, an unsatisfiable core range or a trusted module that cannot be installed fails the whole install before anything is written.
| Member | Type | Notes |
|---|---|---|
modules? | { name: string; version?: string }[] | |
kits? | { key: string; version?: string }[] | |
core? | string |
KitManifest
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | CONTENT_TYPE_KEY_RE |
label | string | |
description | string | |
version | string | |
kind? | `'kit' | 'theme' |
brand? | KitBrand | |
requires? | KitRequirements | |
types | ContentTypeInput[] | |
vocabularies? | KitVocabulary[] | |
collections? | `Omit<Collection, 'id' | 'createdAt' |
forms? | `Omit<FormDefinition, 'id' | 'createdAt' |
menus? | KitMenu[] | |
media? | KitMedia[] | |
pages? | KitPage[] | |
sampleContent? | KitEntity[] |
KitReportItem
Interface.
| Member | Type | Notes |
|---|---|---|
kind | `'type' | 'vocabulary' |
key | string | |
action | `'created' | 'updated' |
reason? | string |
KitInstallReport
Interface.
brandImport: a brand kit's rows waiting for review (Brand → Import brand); nothing else is installed.
| Member | Type | Notes |
|---|---|---|
kit | string | |
version | string | |
items | KitReportItem[] | |
created | number | |
updated | number | |
skipped | number | |
brandImport? | BrandImportResult |
KitSummary
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
description | string | |
version | string | |
kind? | `'kit' | 'theme' |
installed | boolean | |
installedVersion? | string | |
requires? | KitRequirements | |
counts | { types: number; vocabularies: number; collections: number; forms: number; pages: number; sampleContent: number; media?: number } |
KitPlanKit
Interface.
The resolved plan of a kit install (GET /api/kits/:key/plan, POST /api/kits/plan-from-registry): the kits in install order (dependencies first, the requested kit last), the modules that must be present, and the core check.
actionsays what the install would do; a plan whoseokis false names the problem inproblemsand the install is refused with the same message.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
version | string | |
source | `'local' | 'registry'` |
name? | string | |
action | `'install' | 'skip'` |
reason? | string | |
requiredBy? | string |
KitPlanModule
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | |
version? | string | |
action | `'install' | 'skip' |
tier? | ModuleTier | |
installedVersion? | string | |
reason? | string | |
requiredBy | string |
KitInstallPlan
Interface.
| Member | Type | Notes |
|---|---|---|
kit | string | |
version | string | |
ok | boolean | |
problems | string[] | |
kits | KitPlanKit[] | |
modules | KitPlanModule[] | |
core | `{ version: string; range: string | null; ok: boolean }` |
Bulk actions and clone
BulkAction
Type alias.
type BulkAction = 'publish' | 'unpublish' | 'archive' | 'delete' | 'submit' | 'setAuthor' | 'addTerm'
BulkRequest
Interface.
| Member | Type | Notes |
|---|---|---|
ids | string[] | |
action | BulkAction | |
value? | unknown |
BulkResultItem
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
ok | boolean | |
status? | EntityStatus | |
error? | string |
BulkResponse
Interface.
| Member | Type | Notes |
|---|---|---|
results | BulkResultItem[] | |
ok | number | |
failed | number |
BULK_MAX
Constant.
POST /api/entities/bulk (BulkRequest, at most BULK_MAX ids) → BulkResponse. Permissions are checked per item exactly as for the single-entity routes (publish/unpublish need their permissions, delete needs content.delete, submit and setAuthor/addTerm need edit access, archive needs content.unpublish); each success is audited as entity.<action> with
bulk: true.valueis the user id for setAuthor and{ field, termId }(or a bare term id, applied to the first taxonomy field) for addTerm. Publishing through bulk respects the pre-publish checks (no force). POST /api/entities/:id/clone → Entity (201): a draft copy titled "Copy of <title>" with a fresh unique slug, the same data, the caller as author.
const BULK_MAX = 200
Enterprise sign-in (SSO)
SsoProtocol
Type alias.
'github' is OAuth 2 (authorization code, scope user:email) against GitHub or GitHub Enterprise (
issuer= the GitHub origin, default https://github.com); see the registry contract.
type SsoProtocol = 'oidc' | 'saml' | 'github'
SsoProvider
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
slug | string | |
label | string | |
protocol | SsoProtocol | |
enabled | boolean | |
issuer? | string | |
clientId? | string | |
hasClientSecret? | boolean | |
scopes? | string[] | oidc: discovery at <issuer>/.well-known/openid-configuration |
entryPoint? | string | |
idpCert? | string | |
spEntityId? | string | saml: SP metadata at /sso/:slug/metadata |
authParams? | Record<string, string> | oidc: extra query parameters on the authorization request, e.g. Login.gov's acr_values. |
jitProvisioning | boolean | |
defaultRoles | Role[] | |
groupClaim? | string | |
groupRoleMap | Record<string, Role[]> | |
allowedDomains? | string[] | |
createdAt | string | |
updatedAt | string |
SsoProviderInput
Interface (extends Partial<Omit<SsoProvider, 'id' | 'createdAt' | 'updatedAt' | 'hasClientSecret'>>).
| Member | Type | Notes |
|---|---|---|
clientSecret? | `string | null` |
SsoPublicProvider
Interface.
| Member | Type | Notes |
|---|---|---|
slug | string | |
label | string | |
protocol | SsoProtocol |
SsoTestResult
Interface.
POST /api/sso/providers/:id/test → what the IdP answered (OIDC: discovery and JWKS; SAML: the certificate and the SP metadata URL).
| Member | Type | Notes |
|---|---|---|
ok | boolean | |
protocol | SsoProtocol | |
message | string | |
details | Record<string, unknown> |
SSO_SLUG_RE
Constant.
Admin (users.manage): GET/POST /api/sso/providers, GET/PUT/DELETE /api/sso/providers/:id, POST /api/sso/providers/:id/test (OIDC: fetch discovery + JWKS, report issuer/endpoints; SAML: parse cert, report SP metadata URL)
Public: GET /api/sso/providers/public → SsoPublicProvider[] (enabled only)
GET /sso/:slug/start?return=/admin/... → redirect to IdP (OIDC: auth code + PKCE + state + nonce kept in a short-lived httpOnly cookie; SAML: AuthnRequest, HTTP-Redirect binding)
GET /sso/:slug/callback (OIDC) and POST /sso/:slug/acs (SAML) → verify, find or provision user, map roles, create session, redirect to
return(same-origin paths only) or /admin; on failure redirect to /admin/login?error=<code>GET /sso/:slug/metadata (SAML SP metadata XML)
Users gain authProvider ('password' | <slug>) and externalId; password login is refused for SSO-provisioned users without a password; users.manage may set one.
Security: state/nonce/PKCE verified; id_token via JWKS with issuer, audience, 5 min skew; SAML response signature required, InResponseTo, audience, NotOnOrAfter skew; email required and lowercased; allowedDomains enforced; roles replaced by the mapping only when groupClaim is configured; every login audited
auth.sso_login; client secrets encrypted at rest (reuse ai/secrets.ts pattern with asso-secretkey file or the same key).
const SSO_SLUG_RE = /^[a-z][a-z0-9-]{1,40}$/
SSO_FLOW_COOKIE
Constant.
const SSO_FLOW_COOKIE = 'spillway_sso'
SSO_ERROR_MESSAGES
Constant of type Record<string, string>.
Error codes the login screen can explain (
/admin/login?error=<code>).
const SSO_ERROR_MESSAGES = {
provider_not_found: 'That sign-in method is not available.',
provider_disabled: 'That sign-in method is turned off.',
state_mismatch: 'The sign-in session expired or did not match. Try again.',
idp_error: 'The identity provider refused the sign-in.',
invalid_token: 'The identity provider returned a token Spillway could not verify.',
saml_invalid: 'The SAML response could not be verified.',
no_email: 'The identity provider did not send an email address.',
domain_not_allowed: 'Accounts from that email domain cannot sign in here.',
unknown_user: 'No account exists for that ema
// …
Personalization, A/B testing and cookie-free analytics
AudienceRule
Type alias.
type AudienceRule = | { kind: 'utm'; param: 'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_content' | 'utm_term'; equals: string }
| { kind: 'query'; param: string; equals?: string }
| { kind: 'referrer'; contains: string }
| { kind: 'returning' } // first-party cookie sw_v, set only when privacy.consentMode is 'none' or after sw_consent=1
| { kind: 'path'; startsWith: string }
| { kind: 'time'; after?: string; before?: string }
| { kind: 'device'; is: 'mobile' | 'desktop' }
AudienceRuleKind
Type alias.
type AudienceRuleKind = AudienceRule['kind']
AUDIENCE_RULE_KINDS
Constant of type AudienceRuleKind[].
const AUDIENCE_RULE_KINDS = ['utm', 'query', 'referrer', 'returning', 'path', 'time', 'device']
Audience
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | |
key | string | |
label | string | |
rules | AudienceRule[] | |
match | `'all' | 'any'` |
createdAt | string | |
updatedAt | string |
AudienceInput
Type alias.
type AudienceInput = Pick<Audience, 'key' | 'label' | 'rules' | 'match'>
ExperimentStatus
Type alias.
type ExperimentStatus = 'draft' | 'running' | 'paused' | 'ended'
ExperimentGoal
Type alias.
type ExperimentGoal = { kind: 'form'; formKey: string } | { kind: 'click'; selector: string } | { kind: 'path'; path: string }
ExperimentVariant
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
weight | number |
Experiment
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId? | string | |
key | string | |
label | string | |
status | ExperimentStatus | |
variants | ExperimentVariant[] | |
goal | ExperimentGoal | |
startedAt | `string | null` |
endedAt | `string | null` |
createdAt | string | |
updatedAt | string |
ExperimentInput
Type alias.
type ExperimentInput = Pick<Experiment, 'key' | 'label' | 'variants' | 'goal'>
ExperimentResult
Interface.
| Member | Type | Notes |
|---|---|---|
experimentId | string | |
variants | { key: string; exposures: number; conversions: number; rate: number }[] | |
winner? | string | |
confidence? | number |
EXPERIMENT_VARIANT_KEYS
Constant.
Variant keys the Experiment block can show: slots
a,band the optionalc.
const EXPERIMENT_VARIANT_KEYS = ['a', 'b', 'c'] as const
AnalyticsDay
Interface.
Admin (site.settings): GET/POST /api/audiences, GET/PUT/DELETE /api/audiences/:id; GET/POST /api/experiments, GET/PUT/DELETE /api/experiments/:id,
POST /api/experiments/:id/start|pause|end, GET /api/experiments/:id/results (the id may also be the key).
Blocks:
Personalize(audienceKey; slotsdefaultandvariant),Experiment(experimentKey; slotsa,b, optionalc).Islands: cached pages render these blocks as
<div data-sw-island="<entityId>/<blockId>">holding the default/acontent for no-JS and crawlers;one inline script calls GET /_island/:entityId/:blockId?u=<page url>&r=<document.referrer> → { html, exposure?, goal? } for the visitor (no-store,
Vary: Cookie) and swaps it in. Assignment cookie sw_x=<expKey>:<variant>|<expKey>:<variant> (90 days, set by the island response). Matching is
server-side from query, referrer, cookies and the user agent. An element inside
[data-sw-no-island]is left alone.Analytics (cookie-free): the inline script sends navigator.sendBeacon('/_beacon', { p: path, r: referrer host, u: utm params, x: exposures, w: viewport width })
once per view; conversions are recorded server-side on form submissions and
pathgoals, plus POST /_beacon { e: 'click', x: <expKey> } for click goals.Storage: daily rollups per path, referrer host and utm_source, counts only; a per-day salted hash of IP+UA kept only in memory dedupes visitors;
bots (user-agent keyword list) are not counted; 60 beacons/min/IP.
GET /api/analytics/overview?from=&to= → AnalyticsOverview (site.settings or content.publish); GET /api/analytics/pages/:path → AnalyticsPage.
Rows older than ANALYTICS_RETENTION_DAYS are pruned on boot.
| Member | Type | Notes |
|---|---|---|
day | string | |
views | number | |
visitors | number |
AnalyticsOverview
Interface.
| Member | Type | Notes |
|---|---|---|
from | string | |
to | string | |
views | number | |
visitors | number | |
byDay | AnalyticsDay[] | |
topPages | { path: string; views: number; visitors: number }[] | |
topReferrers | { host: string; views: number }[] | |
topSources | { source: string; views: number }[] | |
conversions | `{ total: number; byGoal: { kind: 'form' | 'path' |
AnalyticsPage
Interface.
| Member | Type | Notes |
|---|---|---|
path | string | |
from | string | |
to | string | |
views | number | |
visitors | number | |
byDay | AnalyticsDay[] |
ConsentMode
Type alias.
type ConsentMode = 'none' | 'banner'
SitePrivacy
Interface.
SiteSettings.privacy (PUT /api/site accepts it with site.settings). 'banner': tags and the returning-visitor cookie wait for the consent banner (cookie sw_consent=1).
| Member | Type | Notes |
|---|---|---|
consentMode | ConsentMode |
ANALYTICS_RETENTION_DAYS
Constant.
const ANALYTICS_RETENTION_DAYS = 400
BEACON_RATE_LIMIT_PER_MINUTE
Constant.
const BEACON_RATE_LIMIT_PER_MINUTE = 60
ASSIGNMENT_COOKIE
Constant.
const ASSIGNMENT_COOKIE = 'sw_x'
ASSIGNMENT_COOKIE_DAYS
Constant.
const ASSIGNMENT_COOKIE_DAYS = 90
RETURNING_COOKIE
Constant.
const RETURNING_COOKIE = 'sw_v'
CONSENT_COOKIE
Constant.
const CONSENT_COOKIE = 'sw_consent'
Theme tokens: import, export and the stylesheet
TokenImportFormat
Type alias.
ThemeTokens.custom: extra tokens as kebab-case names → CSS values, emitted as --sw-<name> on :root.
type TokenImportFormat = 'w3c' | 'figma' | 'css' | 'tailwind-json' | 'auto'
TokenImportRequest
Interface.
| Member | Type | Notes |
|---|---|---|
format | TokenImportFormat | |
content | string |
TokenImportResult
Interface.
| Member | Type | Notes |
|---|---|---|
tokens | Partial<ThemeTokens> & { custom?: Record<string, string> } | |
mapped | { source: string; target: string; value: string }[] | |
unmapped | { source: string; value: string }[] | |
warnings | string[] |
ThemeTokenName
Type alias.
POST /api/theme/import { format: 'w3c'|'figma'|'css'|'tailwind-json'|'auto', content: string } → TokenImportResult (site.settings). Does not save. w3c: DTCG JSON with nested groups and "{alias}" references (a top-level
darkgroup, ormodes.dark/themes.dark, and$extensions.mode.darkon a token carry dark mode values; composite shadows become a CSS box-shadow); figma: Figma Variables export JSON (collections → modes → variables; the default or first mode, and a mode named dark as dark mode); css: custom properties on :root; tailwind-json: { theme: { colors, fontFamily, borderRadius, maxWidth } } or the theme object. Mapping heuristics: primary/brand → colorPrimary; secondary → colorSecondary; accent/highlight → colorAccent; neutral/gray/grey → colorNeutral; success/positive → colorSuccess; warning/caution → colorWarning; danger/error/destructive → colorDanger; text/foreground/ink → colorText; background/bg/surface → colorBackground; font body/sans → fontBody; heading/display → fontHeading; font size base/body → fontSizeBase; type scale ratio → typeScaleRatio; space unit/base → spaceUnit; shadow/elevation → shadow; radius → radius; container/max-width → maxWidth; dark mode colors → modes.dark.colors (targetsmodes.dark.<colorToken>); values outside a token's range and the rest → custom. GET /api/theme/tokens.css → :root block as text/css (public, tag theme). GET /api/theme/export → DTCG JSON of the current theme (any user).The seven core tokens, the ones every stylesheet carries.
type ThemeTokenName = 'colorPrimary' | 'colorText' | 'colorBackground' | 'fontBody' | 'fontHeading' | 'radius' | 'maxWidth'
THEME_TOKEN_NAMES
Constant of type ThemeTokenName[].
const THEME_TOKEN_NAMES = ['colorPrimary', 'colorText', 'colorBackground', 'fontBody', 'fontHeading', 'radius', 'maxWidth']
THEME_TOKEN_CSS_VARS
Constant of type Record<ThemeTokenName, string>.
The CSS variable each named token becomes on :root.
const THEME_TOKEN_CSS_VARS = {
colorPrimary: '--sw-color-primary',
colorText: '--sw-color-text',
colorBackground: '--sw-color-bg',
fontBody: '--sw-font-body',
fontHeading: '--sw-font-heading',
radius: '--sw-radius',
maxWidth: '--sw-max-width',
}
ThemeExtendedTokenName
Type alias.
The extended tokens (Brand area), emitted after the core ones; an unset one emits nothing except the type scale and space unit.
type ThemeExtendedTokenName = 'colorSecondary' | 'colorAccent' | 'colorNeutral' | 'colorSuccess' | 'colorWarning' | 'colorDanger' | 'fontSizeBase' | 'typeScaleRatio' | 'spaceUnit' | 'shadow'
THEME_EXTENDED_TOKEN_NAMES
Constant of type ThemeExtendedTokenName[].
const THEME_EXTENDED_TOKEN_NAMES = ['colorSecondary', 'colorAccent', 'colorNeutral', 'colorSuccess', 'colorWarning', 'colorDanger', 'fontSizeBase', 'typeScaleRatio', 'spaceUnit', 'shadow']
THEME_EXTENDED_CSS_VARS
Constant of type Record<ThemeExtendedTokenName, string>.
const THEME_EXTENDED_CSS_VARS = {
colorSecondary: '--sw-color-secondary',
colorAccent: '--sw-color-accent',
colorNeutral: '--sw-color-neutral',
colorSuccess: '--sw-color-success',
colorWarning: '--sw-color-warning',
colorDanger: '--sw-color-danger',
fontSizeBase: '--sw-font-size-base',
typeScaleRatio: '--sw-type-ratio',
spaceUnit: '--sw-space-unit',
shadow: '--sw-shadow',
}
THEME_COLOR_CSS_VARS
Constant of type Record<ThemeColorToken, string>.
The CSS variable of every color token (core and extended).
const THEME_COLOR_CSS_VARS = {
colorPrimary: THEME_TOKEN_CSS_VARS.colorPrimary,
colorText: THEME_TOKEN_CSS_VARS.colorText,
colorBackground: THEME_TOKEN_CSS_VARS.colorBackground,
colorSecondary: THEME_EXTENDED_CSS_VARS.colorSecondary,
colorAccent: THEME_EXTENDED_CSS_VARS.colorAccent,
colorNeutral: THEME_EXTENDED_CSS_VARS.colorNeutral,
colorSuccess: THEME_EXTENDED_CSS_VARS.colorSuccess,
colorWarning: THEME_EXTENDED_CSS_VARS.colorWarning,
colorDanger: THEME_EXTENDED_CSS_VARS.colorDanger,
}
THEME_SHADED_TOKENS
Constant of type readonly ('colorPrimary' | 'colorSecondary' | 'colorAccent' | 'colorNeutral')[].
Shades are generated on the server from the color value (color.ts
colorShades) as--sw-color-<name>-<step>for these tokens and every SHADE_STEPS step (50 to 900; 500 is the color itself, lower steps mix toward white, higher ones toward black). A value that does not parse as a color gets no shades, and a custom token of the same name wins over a generated shade.
const THEME_SHADED_TOKENS = ['colorPrimary', 'colorSecondary', 'colorAccent', 'colorNeutral']
TYPE_SCALE_RATIOS
Constant of type readonly string[].
The type scale:
--sw-text-<step>= fontSizeBase × typeScaleRatio^n for the TYPE_SCALE_STEPS (sm is one step down, base the base size, 3xl four steps up), in the unit of the base size. The ratios the Type tab offers:
const TYPE_SCALE_RATIOS = ['1.125', '1.2', '1.25', '1.333', '1.5']
CUSTOM_TOKEN_NAME_RE
Constant.
const CUSTOM_TOKEN_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/
MAX_CUSTOM_TOKENS
Constant.
const MAX_CUSTOM_TOKENS = 200
Brand area: drafts, publishing and history
BrandDraft
Interface.
The admin's Brand area (Identity, Color, Type, Space and shape, Styles, Voice) edits a per-site brand draft: the theme tokens,
the identity (logos, favicon), the block styles, the brand profile (Voice) and, on a site others inherit from, the locked parts.
Nothing reaches visitors (or the AI tasks) until the draft is published. GET/PUT /api/brand still read and write the current
site's published profile directly (scripts). Existing sites need no migration for the theme: it is the published brand, and
their block styles are the built-in ones; migration 0041 copied the instance-wide brand profile to every site.
The draft as GET /api/brand/draft returns it.
updatedByis a user id (null for a draft written without a user).stylesis the draft's full style list (BlockStyle, see "Block styles"): every built-in style, as the site edited it or as shipped, and the styles the site added, withbuiltIn,modifiedand exactly oneisDefaultper block.
| Member | Type | Notes |
|---|---|---|
theme | ThemeTokens | |
identity | SiteIdentity | |
styles | BlockStyle[] | |
guide | BrandGuideSettings | |
profile | BrandProfile | Brand → Voice, drafted with the rest (the published profile until the draft changes it). |
locks | BrandPart[] | The parts this site locks for the sites that inherit its brand (always empty on a site that inherits one). |
overrides | BrandPart[] | On a site that inherits a brand: the parts this draft overrides (every other part shows the parent's published values). |
updatedAt | string | |
updatedBy | `string | null` |
BrandDraftInput
Interface.
PUT /api/brand/draft body: merged onto the current draft (or onto the published brand when there is none).
styles, when given, replaces the draft's list as a whole: send every style to keep; a built-in style left out is reset, not deleted.profilemerges field by field;locksreplaces the locked parts.
| Member | Type | Notes |
|---|---|---|
theme? | Partial<ThemeTokens> | |
identity? | SiteIdentity | |
styles? | BlockStyleInput[] | |
guide? | BrandGuideSettings | |
profile? | Partial<Omit<BrandProfile, 'updatedAt'>> | |
locks? | BrandPart[] |
BrandRevision
Interface.
One published version.
stylesholds the site's own styles as published (SiteSettings.blockStyles);profileis null for versions published before 0041.
| Member | Type | Notes |
|---|---|---|
id | string | |
theme | ThemeTokens | |
identity | SiteIdentity | |
styles | BlockStyle[] | |
guide | BrandGuideSettings | |
profile | `BrandProfile | null` |
locks | BrandPart[] | |
publishedBy | `string | null` |
publishedAt | string |
BrandPublishResult
Interface.
| Member | Type | Notes |
|---|---|---|
revision | BrandRevision | |
site | SiteSettings |
BRAND_REVISIONS_KEPT
Constant.
How many published versions each site keeps (older ones are pruned when a new one is published).
const BRAND_REVISIONS_KEPT = 20
BRAND_PREVIEW_PARAM
Constant.
The query parameter that renders a public page with the brand draft for a signed-in user with site.settings.
const BRAND_PREVIEW_PARAM = '__brand'
Shared brands (multisite) and brand kits
BrandPart
Type alias.
A brand travels as a kit (
kind: "brand", KitBrand) and, on a multisite instance, can be shared: a child site inherits a parentsite's published brand and overrides only the parts the parent leaves unlocked. A university changes its primary color once and
every department site follows. One level only: a parent cannot inherit, and a child cannot be a parent.
type BrandPart = 'identity' | 'color' | 'type' | 'space' | 'voice' | 'styles'
BRAND_PARTS
Constant of type readonly BrandPart[].
In the order of the Brand tabs.
const BRAND_PARTS = ['identity', 'color', 'type', 'space', 'styles', 'voice']
BRAND_PART_LABELS
Constant of type Record<BrandPart, string>.
const BRAND_PART_LABELS = { identity: 'Identity', color: 'Color', type: 'Type', space: 'Space and shape', styles: 'Styles', voice: 'Voice' }
BRAND_PART_TOKENS
Constant of type Record<'color' | 'type' | 'space', readonly (keyof ThemeTokens)[]>.
The theme tokens each part owns: Color the nine colors and
modes(dark and high contrast), Type the fonts, the base size and the scale ratio, Space and shape the radius, the max width, the space unit, the shadow and the custom tokens. Identity is the logos and favicon, Styles the block styles, Voice the brand profile. The style guide setting belongs to no part: every site keeps its own.
const BRAND_PART_TOKENS = {
color: ['colorPrimary', 'colorSecondary', 'colorAccent', 'colorNeutral', 'colorText', 'colorBackground', 'colorSuccess', 'colorWarning', 'colorDanger', 'modes'],
type: ['fontBody', 'fontHeading', 'fontSizeBase', 'typeScaleRatio'],
space: ['radius', 'maxWidth', 'spaceUnit', 'shadow', 'custom'],
}
brandImportRowPart()
Function.
The part a brand import row writes (by its
id), or null for the style guide setting.
function brandImportRowPart(rowId: string): BrandPart | null
BrandSiteRef
Interface.
A site as the inheritance names it.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | |
name | string |
BrandInheritance
Interface.
GET /api/brand/inheritance: how the current site's brand relates to others. On a child:
parent, the parts the parent locks (locked, as published) and, per the draft (or as published without one), the parts that follow the parent (inherited, locked ones included) and the ones the child changed (overridden). On a parent: itschildren, andlockedas published (the draft's are in BrandDraft.locks). A site with neither answers empty lists and a null parent.
| Member | Type | Notes |
|---|---|---|
parent | `BrandSiteRef | null` |
children | BrandSiteRef[] | |
locked | BrandPart[] | |
inherited | BrandPart[] | |
overridden | BrandPart[] |
KitBrand
Interface.
The brand a kind "brand" kit carries. Identity values are
$media:<key>references to the kit's media (the logo files).
| Member | Type | Notes |
|---|---|---|
theme | Partial<ThemeTokens> | |
identity? | Partial<Record<keyof SiteIdentity, string>> | |
styles? | BlockStyleInput[] | The site's own block styles (built-in styles it edited and styles it added), as BlockStyleInput. |
guide? | BrandGuideSettings | |
profile? | Partial<Omit<BrandProfile, 'updatedAt'>> |
Brand import (Brand → Import brand)
BrandImportSource
Type alias.
Most organizations already have a brand somewhere; the Brand area reads it instead of asking for it to be retyped. Every source
produces an import result for review (what maps to what, the current value next to the imported one), and applying writes only
the rows a person checked into the brand draft. Nothing is ever published by an import.
type BrandImportSource = 'tokens' | 'website' | 'pdf' | 'kit'
BrandImportGroup
Type alias.
The review groups, in the order the review screen shows them (
contrast,stylesandguidecome from brand kits only).
type BrandImportGroup = 'color' | 'type' | 'space' | 'dark' | 'contrast' | 'custom' | 'identity' | 'styles' | 'voice' | 'guide'
BRAND_IMPORT_GROUPS
Constant of type readonly BrandImportGroup[].
const BRAND_IMPORT_GROUPS = ['color', 'type', 'space', 'dark', 'contrast', 'custom', 'identity', 'styles', 'voice', 'guide']
BrandImportRow
Interface.
One reviewable row.
idnames what it writes:theme.<token>(a named token),theme.modes.dark.enabledandtheme.modes.dark.<colorToken>(dark mode),theme.modes.highContrast.<colorToken>(high contrast),theme.custom.<name>(a custom token),identity.<key>(a logo, filed in the media library on apply),styles.<block>.<key>(a block style),guide.public(the style guide setting) orprofile.<field>(the voice).currentis the draft's value (or the published one) when the import was read; null when unset.
| Member | Type | Notes |
|---|---|---|
id | string | |
group | BrandImportGroup | |
label | string | |
source | string | |
value | string | |
current | `string | null` |
BrandImportLeftOut
Interface.
Something the source carried that the import did not map, and why.
| Member | Type | Notes |
|---|---|---|
source | string | |
value | string | |
reason | string |
BrandImportLogo
Interface.
A logo the import found (website) or was given (PDF source): filed in the media library only when its row is applied.
| Member | Type | Notes |
|---|---|---|
filename | string | |
mime | `string | null` |
bytes | `number | null` |
src | `string | null` |
BrandImportProfile
Type alias.
The voice fields an import can draft (the brand profile without its timestamp).
type BrandImportProfile = Partial<Omit<BrandProfile, 'updatedAt'>>
BrandImportResult
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
source | BrandImportSource | |
label | string | What was read: the token format, the URL, or the PDF's file name. |
theme | Partial<ThemeTokens> | Every theme value the import found (named tokens, dark mode, custom tokens). |
identity? | Partial<Record<keyof SiteIdentity, BrandImportLogo>> | |
profile? | BrandImportProfile | |
styles? | BlockStyleInput[] | A brand kit's block styles (BlockStyleInput), applied per styles.<block>.<key> row. |
guide? | BrandGuideSettings | A brand kit's style guide setting. |
mapped | BrandImportRow[] | |
unmapped | BrandImportLeftOut[] | |
warnings | string[] | |
ai | `'used' | 'off' |
createdAt | string | |
expiresAt | string | Unapplied imports are deleted after BRAND_IMPORT_TTL_HOURS. |
BrandImportRequest
Type alias.
POST /api/brand/imports body: design tokens (the formats of POST /api/theme/import) or a website's home page.
type BrandImportRequest = { source: 'tokens'; format: TokenImportFormat; content: string } | { source: 'website'; url: string } | { source: 'kit'; content: string }
BrandImportApplyRequest
Interface.
| Member | Type | Notes |
|---|---|---|
rows | string[] |
BrandImportApplyResult
Interface.
profileechoes the voice fields the checked rows wrote to the draft (null when none): like every other row they land in the brand draft, and the published profile changes only when the draft is published.draftis the draft after the apply (null only when nothing was applied and the site has no draft).
| Member | Type | Notes |
|---|---|---|
draft | `BrandDraft | null` |
profile | `BrandImportProfile | null` |
applied | string[] | |
skipped | { id: string; reason: string }[] |
BRAND_IMPORT_PDF_MAX_BYTES
Constant.
const BRAND_IMPORT_PDF_MAX_BYTES = 20 * 1024 * 1024
BRAND_IMPORT_LOGO_MAX_BYTES
Constant.
const BRAND_IMPORT_LOGO_MAX_BYTES = 5 * 1024 * 1024
BRAND_IMPORT_MAX_LOGOS
Constant.
const BRAND_IMPORT_MAX_LOGOS = 4
BRAND_IMPORT_TTL_HOURS
Constant.
const BRAND_IMPORT_TTL_HOURS = 24
Block styles (Brand → Styles)
BLOCK_STYLED_CORE_BLOCKS
Constant.
The component library is the block library plus named styles. Each styled block declares its style slots (what can vary: a
Hero's background, text and layout; a Card's border, fill and image position; a Button's emphasis), each slot a short list of
values that map to theme tokens or fixed keywords, never raw colors. The brand holds a short list of named styles per block,
built only from those values; an editor picks a style by name in the page builder's Style field and never sees a color.
Stored as a name: a block keeps
style: "dark-band"in its props. Changing a style's definition restyles every page that uses it(the page cache is purged by tag). Every styled block has a default style, used when a block carries no style or one the site
does not have; the built-in defaults reproduce the blocks' look before styles existed, so existing pages are unchanged.
The core blocks with styles are BLOCK_STYLED_CORE_BLOCKS; their slots and built-in styles live in @spillway-cms/blocks
(
blockStyleSlots,builtInBlockStyles), so the starter themes share one set of styles that their tokens recolor.Module blocks take part through their manifest (ModuleBlockDef):
styles: "Cards"reuses a core block's style set (the blockthen follows the site's Card styles), or
styleSlotsdeclares the block's own slots (its style set is then<module>/<blockKey>,with one built-in style,
default, made of each slot's default). The module's render receives the style key asprops.styleand the slot values as
props.styleValues; the wrapper carries the style's class.Per role: a style with
rolesis offered in the page builder only to people with one of those site roles (an empty list iseveryone); people with site.settings may use every style. A block that already carries a style the person may not use keeps
it (the builder shows it read only); the server refuses a save that gives a block such a style (403).
The core blocks that have style slots.
const BLOCK_STYLED_CORE_BLOCKS = ['Hero', 'Cards', 'Button', 'CallToAction', 'Heading', 'Text'] as const
StyledCoreBlock
Type alias.
type StyledCoreBlock = (typeof BLOCK_STYLED_CORE_BLOCKS)[number]
BLOCK_STYLE_KEY_RE
Constant.
A style key: kebab-case, unique per block. Also the shape of a slot value.
const BLOCK_STYLE_KEY_RE = /^[a-z][a-z0-9-]{0,39}$/
BLOCK_STYLE_SLOT_RE
Constant.
A slot key: camelCase or kebab-case.
const BLOCK_STYLE_SLOT_RE = /^[a-z][a-zA-Z0-9-]{0,31}$/
MAX_STYLES_PER_BLOCK
Constant.
const MAX_STYLES_PER_BLOCK = 24
MAX_BLOCK_STYLES
Constant.
const MAX_BLOCK_STYLES = 300
blockStyleTag()
Function.
The cache tag of every page that renders a block with this style (the stored style and the one rendered, when they differ).
function blockStyleTag(block: string, key: string): string
blockStyleClass()
Function.
The class a styled block carries:
sw-style-<block>-<key>for a core block (sw-style-hero-dark-band), andsw-style-m-<module>-<blockKey>-<key>for a module block with its own slots (scope, slashes and other characters folded to dashes).
function blockStyleClass(block: string, key: string): string
moduleStyleSet()
Function.
The style set of a module block's own slots.
function moduleStyleSet(module: string, blockKey: string): string
BlockStyleSlotOption
Interface.
One value a slot offers;
valuematches BLOCK_STYLE_KEY_RE.
| Member | Type | Notes |
|---|---|---|
value | string | |
label | string |
BlockStyleSlot
Interface.
What a styled block lets vary: one slot, its allowed values and the value a new style starts with.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
help? | string | |
options | BlockStyleSlotOption[] | |
default | string |
BlockStyle
Interface.
A named block style.
blockis the style set: a core block type ("Hero") or a module block's own set ("<module>/<blockKey>").tokensmaps every slot of the block to one of its values (a slot left out takes its default).roleslimits who may pick it in the page builder (empty or absent: everyone).builtInandmodifiedare set by the server on output: a built-in style can be edited and reset, never deleted, and the default can move to any style of the block.
| Member | Type | Notes |
|---|---|---|
block | string | |
key | string | |
label | string | |
description? | string | |
tokens | Record<string, string> | |
roles? | Role[] | |
isDefault? | boolean | |
builtIn? | boolean | |
modified? | boolean |
BlockStyleInput
Type alias.
type BlockStyleInput = Omit<BlockStyle, 'builtIn' | 'modified'>
BuilderBlockStyle
Interface (extends BlockStyle).
A style as the page builder sees it:
availableis false when the person's role may not pick it.
| Member | Type | Notes |
|---|---|---|
available | boolean |
BlockStylesInfo
Interface.
GET /api/brand/styles → the published styles and the slots of every styled block (core blocks and loaded module blocks).
| Member | Type | Notes |
|---|---|---|
styles | BlockStyle[] | |
slots | Record<string, BlockStyleSlot[]> |
Style guide (Brand → Style guide, /brand)
BrandGuideSettings
Interface.
The style guide renders the brand as a page, built from the tokens on every request (so it is never out of date): identity
(each logo on light and on dark, the favicon, download links to the original files), the palette with every shade and its hex
value, the contrast grid (contrastGrid), the type scale with sample text, spacing, radius and shadow, the voice rules from the
brand profile, and every block that has styles in every style, rendered by the same path as pages with sample props
(
blockStyleSamplePropsin @spillway-cms/blocks). It prints as the brand book (@media print: a page per section, colors kept).The style guide setting, drafted and published with the brand.
publicputs the published guide at BRAND_GUIDE_PATH.
| Member | Type | Notes |
|---|---|---|
public | boolean |
BRAND_GUIDE_PATH
Constant.
Where a site publishes its style guide when BrandGuideSettings.public is on. Off by default.
const BRAND_GUIDE_PATH = '/brand'
BRAND_GUIDE_TAG
Constant.
The cache tag of the public style guide: purged when the brand profile (Voice) changes. Brand publishes purge
theme.
const BRAND_GUIDE_TAG = 'brand-guide'
BrandGuideSectionId
Type alias.
type BrandGuideSectionId = 'identity' | 'color' | 'contrast' | 'type' | 'space' | 'voice' | 'styles'
BRAND_GUIDE_SECTIONS
Constant of type readonly { id: BrandGuideSectionId; label: string }[].
The sections in the order the guide shows them.
const BRAND_GUIDE_SECTIONS = [
{ id: 'identity', label: 'Identity' },
{ id: 'color', label: 'Color' },
{ id: 'contrast', label: 'Contrast' },
{ id: 'type', label: 'Type' },
{ id: 'space', label: 'Space and shape' },
{ id: 'voice', label: 'Voice' },
{ id: 'styles', label: 'Block styles' },
]
THEME_COLOR_LABELS
Constant of type Record<ThemeColorToken, string>.
The names the Brand area gives the color tokens.
const THEME_COLOR_LABELS = {
colorPrimary: 'Primary',
colorSecondary: 'Secondary',
colorAccent: 'Accent',
colorNeutral: 'Neutral',
colorText: 'Text',
colorBackground: 'Background',
colorSuccess: 'Success',
colorWarning: 'Warning',
colorDanger: 'Danger',
}
BrandGuideLogo
Interface.
One identity file the guide shows and links for download (the original media file).
| Member | Type | Notes |
|---|---|---|
key | keyof SiteIdentity | |
label | string | |
mediaId | string | |
url | string | |
filename | string | |
mime | string |
BrandGuide
Interface.
GET /api/brand/guide → BrandGuide.
htmlis a standalone HTML document (theme tokens, the block stylesheet and the rules of every style it shows inlined; no scripts) for an iframe or a print window; with site.settings it carries a "Duplicate style" button per style (data-sw-duplicate-block,data-sw-duplicate-key), hidden in print.stylesis the full style list the guide shows;publicis this brand's style guide setting.
| Member | Type | Notes |
|---|---|---|
brand | `'published' | 'draft'` |
html | string | |
sections | { id: BrandGuideSectionId; label: string }[] | |
styles | BlockStyle[] | |
logos | BrandGuideLogo[] | |
public | boolean | |
updatedAt | `string | null` |
Navigation menus
MenuItem
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
label | string | |
labels? | Record<string, string> | Per-locale labels (locale → label); the request locale's label wins over label when present. |
href? | string | |
entityId? | string | |
newTab? | boolean | |
children? | MenuItem[] |
Menu
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | |
label | string | |
items | MenuItem[] | |
createdAt | string | |
updatedAt | string | Multisite: the owning site (keys are unique per site). |
siteId? | string |
MenuInput
Type alias.
type MenuInput = { key: string; label: string; items: MenuItem[] }
MENU_KEY_RE
Constant.
GET /api/menus → Menu[]; POST (MenuInput); GET/PUT (Partial<MenuInput>)/DELETE /api/menus/:id (site.settings writes, any user reads). Keys
mainandfooterare created by migration/setup and cannot be deleted (409). Items linking an entity resolve to its current path at render time and are hidden while unpublished; pages carry tag menu:<key>; menu changes and path changes invalidate. Render: header showsmain(two levels, <details>-based submenus and mobile toggle, no JS), footer showsfooter; aria-current="page" on the active item.
const MENU_KEY_RE = /^[a-z][a-z0-9_-]{0,62}$/
PROTECTED_MENU_KEYS
Constant.
const PROTECTED_MENU_KEYS = ['main', 'footer'] as const
MENU_MAX_DEPTH
Constant.
const MENU_MAX_DEPTH = 2
MENU_MAX_ITEMS
Constant.
const MENU_MAX_ITEMS = 200
Workstreams
WorkstreamStatus
Type alias.
type WorkstreamStatus = 'open' | 'merging' | 'merged' | 'discarded'
Workstream
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
key | string | CONTENT_TYPE_KEY_RE-like: ^[a-z][a-z0-9_-]*$, unique per site |
label | string | |
description? | string | |
status | WorkstreamStatus | |
ownerId | string | |
publishAt | `string | null` |
createdAt | string | |
updatedAt | string | |
mergedAt | `string | null` |
itemCount | number | |
lastMergeError? | `string | null` |
WorkstreamItemKind
Type alias.
type WorkstreamItemKind = 'entity' | 'menu' | 'settings'
WorkstreamItemAction
Type alias.
type WorkstreamItemAction = 'update' | 'create' | 'delete'
WorkstreamItem
Interface.
| Member | Type | Notes |
|---|---|---|
workstreamId | string | |
kind | WorkstreamItemKind | |
targetId | string | entity id, menu id, or "default" for the site settings |
revisionId? | string | entities: the workstream revision that holds the branch version |
action | WorkstreamItemAction | |
updatedAt | string | |
authorId | string | |
baseVersion? | `string | null` |
title? | string |
WorkstreamDiffItem
Interface.
| Member | Type | Notes |
|---|---|---|
kind | WorkstreamItemKind | |
targetId | string | |
title | string | |
action | WorkstreamItemAction | |
fieldChanges | number | |
layoutChanges | number | |
checks | EntityCheck[] | |
revisionId? | string | Entities: the workstream revision and the main-line revision it is compared with (GET /api/entities/:id/revisions/:a/diff/:b for the details). |
baseRevisionId? | `string | null` |
path? | `string | null` |
WorkstreamConflict
Interface.
| Member | Type | Notes |
|---|---|---|
targetId | string | |
kind | WorkstreamItemKind | |
reason | `'live_changed' | 'deleted'` |
WorkstreamDiff
Interface.
| Member | Type | Notes |
|---|---|---|
items | WorkstreamDiffItem[] | |
conflicts | WorkstreamConflict[] |
WorkstreamInput
Interface.
| Member | Type | Notes |
|---|---|---|
key? | string | |
label | string | |
description? | string |
WorkstreamShareLink
Interface.
| Member | Type | Notes |
|---|---|---|
token | string | |
url | string | |
expiresAt | string |
WorkstreamMergeResult
Interface.
| Member | Type | Notes |
|---|---|---|
workstream | Workstream | |
merged | { kind: WorkstreamItemKind; targetId: string; action: WorkstreamItemAction }[] |
WORKSTREAM_HEADER
Constant.
GET /api/workstreams?status= → Workstream[] (content.view_drafts); POST /api/workstreams (WorkstreamInput) → Workstream (content.create; the caller owns it) GET /api/workstreams/:id → Workstream & { items: WorkstreamItem[] }; PUT /api/workstreams/:id ({ label?, description? }); DELETE /api/workstreams/:id → 204 (PUT, DELETE, discard and share-link: the owner or content.publish; the id may also be the key) POST /api/workstreams/:id/merge ({ force?: boolean }) → WorkstreamMergeResult (content.publish). Publishes every item through the normal publish path (pre-publish checks gate, audit rows, cache invalidation, entity.published and workstream.merged notifications) in one transaction. Refuses with 409
workstream_conflicts(details: WorkstreamConflict[]) when the main line moved since the branch was taken, and with 422checks_failed(details: { targetId, title, checks }[]) when any item has an error-level check, unlessforce. After a merge the workstream is read-only. POST /api/workstreams/:id/discard → Workstream (status discarded; the branch revisions, overlays and entities created inside it are deleted) POST /api/workstreams/:id/schedule ({ publishAt: string | null }) → Workstream (content.publish): the ticker merges it when the time comes with force false; a refused merge is recorded in lastMergeError and retried every tick. GET /api/workstreams/:id/diff → WorkstreamDiff: every item with the number of changed fields and layout blocks (content/diff.ts) and its pre-publish checks. POST /api/workstreams/:id/share-link → WorkstreamShareLink: a token valid WORKSTREAM_SHARE_DAYS that lets a stakeholder open the preview without a session.Editing inside a workstream: send the header
X-Spillway-Workstream: <id>(?workstream=<id>on preview URLs). While set: PUT /api/entities/:id writes a revision tagged with the workstream (Revision.workstreamId) and leaves the main line's currentRevisionId, title and data alone (slug and locale cannot change inside a workstream); POST /api/entities creates an entity that only the workstream sees until merge; DELETE /api/entities/:id records a delete intent; PUT /api/menus/:id and PUT /api/site are stored as workstream overlays. Publish, unpublish, submit and restore are refused with 409workstream_scope; merge is how a workstream reaches the live site. GET /api/entities/:id returns the workstream version (inWorkstream: true,workstreamAction); GET /api/entities lists the main line plus the entities created in the workstream and marks the ones the workstream changes withinWorkstream; GET /api/entities/:id/checks and /revisions follow the header. GET /api/menus and GET /api/site return the overlaid versions. Preview: GET /_preview/ws/:id/<path> renders the site as it would look after the merge (branch revisions, created pages, delete intents, menu and settings overlays), with internal links rewritten to stay inside the preview; noindex, no-store, cache bypassed; requires content.view_drafts or a valid?token=share token./_preview/ws/:idand/_preview/ws/:id/render the home page. Merge semantics: for each item, when the main line's current revision id (entities) or updatedAt (menus, settings) still equals the base the branch was taken from, the change applies; otherwise it is a conflict. The merge writes a new main-line revision from the workstream revision and publishes it (or deletes the entity, or applies the overlay). Notification eventworkstream.mergedcarries the workstream and the item count.
const WORKSTREAM_HEADER = 'x-spillway-workstream'
WORKSTREAM_QUERY
Constant.
const WORKSTREAM_QUERY = 'workstream'
WORKSTREAM_KEY_RE
Constant.
const WORKSTREAM_KEY_RE = /^[a-z][a-z0-9_-]{1,62}$/
WORKSTREAM_SHARE_DAYS
Constant.
const WORKSTREAM_SHARE_DAYS = 7
WORKSTREAM_STATUSES
Constant of type WorkstreamStatus[].
const WORKSTREAM_STATUSES = ['open', 'merging', 'merged', 'discarded']
Localization contract (Phase 3)
TranslationStatus
Type alias.
Localization model: an entity is one translation. Translations of the same content share
translationGroupId; the source translation is the one withisSource: true. Field-level sharing: fields with translatable: false are copied from the source on create and kept in sync on source save (configurable per field; default translatable true for text-like, false for media, reference, boolean, number, date, daterange, taxonomy, layout? (layout is translatable: copied on create, then independent)).Entity gains: translationGroupId, isSource, translationStatus: 'current' | 'outdated' | 'missing' (outdated when the source changed after this translation's last save).
Routing: locale prefix in the path for non-default locales (/es/about); default locale unprefixed;
Site.localePrefixDefaultboolean to prefix the default too. Paths are resolved per (site, locale, path).hreflangalternates and x-default in the head, per-locale sitemaps (/sitemap.xml index → /sitemap-<locale>.xml),<html lang>, menus per locale (menu items carry optional per-locale labels and link to the translation), search per locale with a locale filter, collections filter by the request locale by default.API: GET /api/entities/:id/translations → { group: Entity[]; missing: string[] }; POST /api/entities/:id/translations ({ locale, mode: 'empty' | 'copy' | 'ai' }) → Entity (draft; 'ai' uses the translate task with the brand glossary and marks fields; requires ai.use); PUT marks outdated on source save; GET /api/entities?locale=; GET /api/translations/status → counts per locale and a list of outdated or missing items (content.view_drafts). Locale management: PUT /api/site { locales, defaultLocale, localePrefixDefault } validates BCP 47; removing a locale with content is refused unless ?force.
Admin UI strings: GET /api/i18n/:locale → message bundle for the admin UI (ship en and a mechanism; include es as a demo with ~50 strings) and a
user.uiLocalepreference.
type TranslationStatus = 'current' | 'outdated' | 'missing'
TranslationMode
Type alias.
type TranslationMode = 'empty' | 'copy' | 'ai'
TranslationsResponse
Interface.
GET /api/entities/:id/translations.
groupholds every translation (source first),missingthe site locales with no translation yet.
| Member | Type | Notes |
|---|---|---|
group | Entity[] | |
missing | string[] |
TranslationCreateRequest
Interface.
POST /api/entities/:id/translations.
emptycopies shared fields and the title only;copycopies everything;aitranslates text fields and layout copy (ai.use).
| Member | Type | Notes |
|---|---|---|
locale | string | |
mode | TranslationMode |
TranslationCreateResponse
Interface (extends Entity).
The created draft, plus which fields the model wrote (
aimode) and the usage it cost.
| Member | Type | Notes |
|---|---|---|
aiTranslatedFields? | string[] | |
usage? | AiUsage |
TranslationStatusReport
Interface.
GET /api/translations/status (content.view_drafts). Counts per locale over every source entity, and the outdated or missing items (up to 500).
| Member | Type | Notes |
|---|---|---|
defaultLocale | string | |
locales | Record<string, { total: number; current: number; outdated: number; missing: number }> | |
items | `{ sourceId: string; type: string; title: string; locale: string; status: 'outdated' | 'missing'; entityId: string |
I18nBundle
Interface.
GET /api/i18n/:locale (public).
messagesis the requested bundle over theenone, so every key resolves;localeis the bundle actually served.
| Member | Type | Notes |
|---|---|---|
locale | string | |
available | string[] | |
messages | Record<string, string> |
isTranslatableField()
Function.
Field-level sharing between translations.
translatable: truefields hold locale-specific values;translatable: falsefields are copied from the source when a translation is created and overwritten again on every source save. Defaults by type: text, textarea, richtext, select, link, json and layout are translatable (layout is copied on create and then independent, never synced); number, boolean, date, daterange, reference, media and taxonomy are shared.
function isTranslatableField(field: Pick<FieldDef, 'type' | 'translatable'>): boolean
LOCALE_RE
Constant.
BCP 47 language tag shape (language, optional script, region and variants), checked against Intl where available.
const LOCALE_RE = /^[a-zA-Z]{2,3}(?:-[a-zA-Z]{4})?(?:-(?:[a-zA-Z]{2}|\d{3}))?(?:-(?:[a-zA-Z0-9]{5,8}|\d[a-zA-Z0-9]{3}))*$/
isValidLocale()
Function.
function isValidLocale(tag: unknown): tag is string
canonicalLocale()
Function.
pt-br→pt-BR,EN→en; falls back to the input when Intl refuses it.
function canonicalLocale(tag: string): string
UI_LOCALES
Constant.
The admin UI locales that ship with a message bundle;
enis the fallback for every missing key.
const UI_LOCALES = ['en', 'es'] as const
Multisite and department delegation (Phase 3)
SiteStatus
Type alias.
Sites: one instance hosts many sites. The default site keeps id 'default'. Site resolution by Host header (primary domain,
aliases; case-insensitive, port stripped), then by
?__site=<id|key>(or the header X-Spillway-Site, SITE_HEADER) forpreviews and the admin switcher when the user can open that site, else the default site.
type SiteStatus = 'active' | 'paused'
Site
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | SITE_KEY_RE, unique |
name | string | |
domains | string[] | host names (no scheme, no port), unique across sites; aliases of primaryDomain |
primaryDomain | `string | null` |
defaultLocale | string | |
locales | string[] | |
status | SiteStatus | 'paused': the public site answers 503, the API still works |
brandParentId | `string | null` |
createdAt | string | |
updatedAt | string |
SiteInput
Interface.
brandParent(id or key, null to stop inheriting) is set by sites.manage only; see "Shared brands".
| Member | Type | Notes |
|---|---|---|
key | string | |
name | string | |
domains? | string[] | |
primaryDomain? | `string | null` |
defaultLocale? | string | |
locales? | string[] | |
status? | SiteStatus | |
brandParent? | `string | null` |
UserSiteRole
Interface.
GET /api/sites → Site[]; POST /api/sites (SiteInput) → Site (201); GET/PUT (Partial<SiteInput>)/DELETE /api/sites/:id (the id may be the key). Permission: sites.manage
(admin only). Creating a site seeds its
mainandfootermenus and default settings (theme, SEO); deleting one removes its content, media rows, menus,redirects, collections, forms and submissions, alerts, audiences, experiments and analytics rollups. The default site cannot be deleted.
Every site-scoped table carries site_id (entity, revision, media, menu, redirect, path_miss, collection, form, form_submission, site_alert, audience,
experiment, analytics rollups, search_index); existing rows were backfilled with 'default'. Vocabularies, terms, content types, shared blocks,
notification rules, API tokens, SSO providers and modules are instance-wide. SiteSettings stays per site in the
sitetable row (GET/PUT /api/siteact on the request's site).
Request scoping: middleware sets the site on every request; the public renderer, sitemap, robots, llms.txt, feeds, search, redirects, alerts, islands,
theme CSS and the beacon are per site; cache keys include the site id; every page carries the tag site:<id>. Menu, collection, form, redirect,
audience and experiment keys are unique per site.
User access per site:
user_site_role(user_id, site_id, roles). A user's effective roles on a site = global roles ∪ site roles; a user may have noglobal roles at all (roles: []) and only site or section roles. Admins (global 'admin') see all sites; a user with any global role can open every
site; others see only the sites where they hold a site or section role. GET /api/me/sites → MeSitesResponse (for the admin site switcher).
| Member | Type | Notes |
|---|---|---|
siteId | string | |
roles | Role[] |
UserSectionRole
Interface.
| Member | Type | Notes |
|---|---|---|
sectionId | string | |
roles | Role[] |
MeSitesResponse
Interface.
| Member | Type | Notes |
|---|---|---|
sites | (Site & { roles: Role[] })[] | |
currentSiteId | `string | null` |
Section
Interface.
PUT /api/users/:id/site-roles ({ siteRoles: UserSiteRole[] }) → UserSiteRole[] replaces the user's site roles (an entry with roles: [] removes it);
GET /api/users/:id/site-roles → UserSiteRole[]. PUT /api/users/:id/section-roles ({ sectionRoles: UserSectionRole[] }) → UserSectionRole[];
GET /api/users/:id/section-roles. Permission: users.manage, and the actor must hold users.manage on every site touched (a global admin always
does). Every change is audited (user.site_roles, user.section_roles) and ends the user's other sessions.
Sections (department delegation): a site section owns the content under a path prefix and, optionally, one menu.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
key | string | CONTENT_TYPE_KEY_RE-like, unique per site |
label | string | |
pathPrefix | string | e.g. "/engineering"; content whose path equals it or starts with "<prefix>/" belongs to the section |
menuKey? | string | a menu of the site the section's editors may edit |
createdAt | string | |
updatedAt | string |
SectionInput
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
pathPrefix | string | |
menuKey? | `string | null` |
MeSection
Interface.
What GET /api/auth/me reports per section the user has a role in on the request's site.
| Member | Type | Notes |
|---|---|---|
id | string | |
key | string | |
label | string | |
pathPrefix | string | |
menuKey? | string | |
roles | Role[] | |
permissions | Permission[] |
SECTION_PERMISSIONS
Constant of type Permission[].
GET /api/sections → Section[] (any user; section editors see only their own), POST /api/sections (SectionInput) → Section (201, site.settings),
GET/PUT (Partial<SectionInput>)/DELETE /api/sections/:id (site.settings; the id may be the key). Creating a section (or changing its prefix) assigns
the site's unassigned entities under the prefix to it; deleting it clears sectionId on its entities and removes its role assignments.
Entities gain optional sectionId (auto-assigned from the longest matching path prefix on every save when empty;
sectionId: nullin a PUT asksfor re-assignment from the path).
user_section_role(user_id, section_id, roles): a section role grants its role's content permissions(SECTION_PERMISSIONS) only on entities in that section. A section editor may create content whose path falls under the prefix (a slug may
contain "/" to build a nested path, e.g. "engineering/labs"; when a section-only user's slug would land outside every section they hold, the
first section's prefix is prepended), may edit the section's menu (PUT /api/menus/:id with content.publish in that section), and can never
touch site settings, types, forms, users or other sections' content. List endpoints (GET /api/entities, GET /api/review/queue) return only
the user's sections when the user has no site-wide role; GET /api/entities/:id and every entity action are refused (403) outside them.
Site-wide permissions reported by GET /api/auth/me for a section-only user are the ambient subset (view drafts, create, submit, upload media,
use AI); the per-section permissions come with
sections. Every role assignment is audited.
const SECTION_PERMISSIONS = [
'content.view_drafts', 'content.create', 'content.edit_own', 'content.edit_any', 'content.delete',
'content.submit_review', 'content.publish', 'content.unpublish', 'media.upload', 'ai.use',
]
SECTION_AMBIENT_PERMISSIONS
Constant of type Permission[].
The site-wide permissions a section role grants outside its section (lists and entity actions are still scoped to the section).
const SECTION_AMBIENT_PERMISSIONS = ['content.view_drafts', 'content.create', 'content.submit_review', 'media.upload', 'ai.use']
SITE_KEY_RE
Constant.
const SITE_KEY_RE = /^[a-z][a-z0-9_-]{0,62}$/
SECTION_KEY_RE
Constant.
const SECTION_KEY_RE = /^[a-z][a-z0-9_-]{0,62}$/
DOMAIN_RE
Constant.
A host name as stored in Site.domains: lowercase, no scheme, no port, no path.
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
SITE_HEADER
Constant.
const SITE_HEADER = 'x-spillway-site'
SITE_QUERY_PARAM
Constant.
const SITE_QUERY_PARAM = '__site'
DEFAULT_SITE_KEY
Constant.
const DEFAULT_SITE_KEY = 'default'
pathInSection()
Function.
True when
pathfalls under a section's prefix ("/engineering" owns "/engineering" and "/engineering/labs", not "/engineering-news").
function pathInSection(path: string | null | undefined, pathPrefix: string): boolean
slugifyPath()
Function.
slugsegments slugified one by one so an explicit "engineering/labs" keeps its structure; a plain slug behaves like slugify().
function slugifyPath(input: string): string
Registry (the server side; enabled per instance with REGISTRY_HOST=1, meant for spillwaycms.com)
PackageKind
Type alias.
type PackageKind = 'module' | 'theme' | 'kit' | 'brand'
Publisher
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
userId | string | |
handle | string | |
displayName | string | |
url? | string | |
avatarUrl? | string | |
verified | boolean | |
createdAt | string |
Namespace
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | |
ownerPublisherId | string | |
maintainers | string[] | |
createdAt | string | Set when an organization owns the namespace: its owners and admins (plus maintainers) publish under it; ownerPublisherId is then the publisher who transferred it and grants nothing. |
ownerOrgId? | `string | null` |
OrgRole
Type alias.
Organizations: a handle shared by several publishers that owns namespaces. Handles share one space with publisher handles.
type OrgRole = 'owner' | 'admin' | 'member'
RegistryOrg
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
handle | string | |
displayName | string | |
url? | string | |
avatarUrl? | string | |
verified | boolean | |
createdAt | string |
RegistryOrgMember
Interface.
| Member | Type | Notes |
|---|---|---|
publisherId | string | |
handle | string | |
displayName | string | |
role | OrgRole | |
since | string |
RegistryOrgMembership
Interface (extends RegistryOrg).
GET /api/registry/orgs/mine: an organization with the caller's role in it.
| Member | Type | Notes |
|---|---|---|
role | OrgRole |
RegistryOrgDetail
Interface.
GET /api/registry/orgs/:id (members and reviewers) and GET /registry/v1/orgs/:handle (public,
packagesinstead ofnamespaces).
| Member | Type | Notes |
|---|---|---|
org | RegistryOrg | |
members | RegistryOrgMember[] | |
namespaces | Namespace[] |
RegistryPackage
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | |
kind | PackageKind | |
tier? | ModuleTier | |
description | string | |
namespace | `string | null` |
publisherId | string | |
latest | `string | null` |
downloads | number | |
reviewed | boolean | |
deprecated | boolean | |
deprecationNote? | string | |
homepage? | string | |
repository? | string | |
license? | string | |
keywords | string[] | |
createdAt | string | |
updatedAt | string |
PackageVersion
Interface.
| Member | Type | Notes |
|---|---|---|
name | string | |
version | string | |
sha256 | string | |
size | number | |
manifest | `ModuleManifest | KitManifest |
readme | string | |
changelog? | string | |
spillwayRange | string | |
capabilities? | Capability[] | |
checks | `{ id: string; status: 'pass' | 'warn' |
status | `'listed' | 'pending_review' |
yankReason? | string | |
publishedAt | string | |
downloads | number | |
files | string[] | every entry of the tarball, recorded at publish time |
Advisory
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
package | string | |
versions | string | |
severity | `'low' | 'moderate' |
title | string | |
url? | string | |
createdAt | string |
RegistryStats
Interface.
GET /registry/v1/stats
| Member | Type | Notes |
|---|---|---|
packages | number | |
versions | number | |
publishers | number | |
downloads | number | |
byKind | Record<PackageKind, number> | |
pendingReview | number |
AgentPackageManifest
Interface.
GET /registry/v1/packages/:name/manifest.json: what an agent needs to install and configure a package from a conversation.
| Member | Type | Notes |
|---|---|---|
name | string | |
kind | PackageKind | |
tier? | ModuleTier | |
latest | `string | null` |
description | string | |
install | { command: string; api?: string } | |
capabilities | Capability[] | |
config | `{ settings: Record<string, unknown> | null }` |
docs | `{ readme: string; homepage: string | null; repository: string |
advisories | Advisory[] |
REGISTRY_PACKAGE_NAME_RE
Constant.
Package kinds: a module ships
manifest.json(ModuleManifest,tierdecides the review path); a kit shipskit.json(KitManifest, plus optional
name,homepage,repository,license,keywords,spillway); a theme is a kit manifest with"kind": "theme"andscreenshots: string[](paths of PNG/JPEG/WebP files inside the tarball, at least one); a brand is a kit manifest with
"kind": "brand"(KitBrand, logos asmedia), checked by a dry run that reads it into a brand import on a scratch site and confirms nothing went live, and listed under
brandsin the index. The package name is the manifestname(kits: the key with_→-when unset).Publishing: POST /registry/v1/publish (multipart: tarball; bearer token with the
registry.publishpermission, a new Permission granted to everyauthenticated user by default) → 201 { package, version } (or 202 when the version is pending review) → validates (manifest, semver greater than
latest, immutable versions, tier allowed, no secrets in files, size caps 20 MB), extracts README.md and CHANGELOG.md, computes sha256, runs automated
checks (manifest schema, capability review: trusted → pending_review, sandboxed → smoke-load in the Wasm host with a timeout, kits → installer dry run
on a scratch site), stores the tarball under <DATA_DIR>/registry/<name>/<version>.tgz (REGISTRY_STORAGE), lists it. A failed check answers
422 checks_failed with
details.checks. Namespaces: first publish of @ns/x claims ns for the publisher; others need maintainer rights (409 namespace_taken);a later version of an existing package needs its publisher or a namespace maintainer (403 not_owner).
Status: GET /api/registry/status → { enabled: boolean, fourEyes: boolean } answers on every instance (any signed-in user; 200 with enabled: false when
REGISTRY_HOST is unset), so the admin learns whether the Registry screens apply without probing a 404.
fourEyesis REGISTRY_FOUR_EYES=1: approveanswers 403 four_eyes when the reviewer published the version, is the package's publisher or owns or maintains its namespace.
Publisher profile: GET /api/registry/publishers/me → Publisher | null; POST /api/registry/publishers/me ({ handle, displayName, url? }) creates or updates it
(a Publisher row is otherwise created on first publish, handle derived from the email). GET /api/registry/namespaces/mine → Namespace[].
Reviewer listings (users.manage): GET /api/registry/publishers?q=&page=&limit= → Paginated<Publisher> (q matches handle and display name);
GET /api/registry/namespaces?page=&limit= → Paginated<Namespace>.
Review (users.manage on the registry instance): GET /api/registry/review → PackageVersion[] (pending); POST /api/registry/review/:name/:version/approve|reject
({ note? }); POST /api/registry/advisories (Omit<Advisory, 'id' | 'createdAt'>) → 201 Advisory; DELETE /api/registry/advisories/:id;
POST /api/registry/packages/:name/deprecate ({ note? }) | undeprecate; POST /api/registry/packages/:name/versions/:v/yank ({ reason }) (the publisher may
deprecate and yank their own packages too); POST /api/registry/namespaces/:name/transfer ({ ownerPublisherId, maintainers? }); POST /api/registry/publishers/:id/verify
({ verified }). Every mutation writes an audit row (registry.publish, registry.approve, registry.reject, registry.yank, registry.deprecate, registry.undeprecate,
registry.advisory_create, registry.advisory_delete, registry.namespace_transfer, registry.publisher_verify, registry.publisher_update).
Organizations (migration 0031: registry_org, registry_org_member, namespace.owner_org_id): a handle (REGISTRY_HANDLE_RE, must not collide with a publisher
handle, and a publisher handle must not collide with an org) with members (publishers) in the roles owner | admin | member. An org owns namespaces:
its owners and admins (and the namespace's
maintainers) may publish under them and deprecate or yank their packages; a plain member may not(403 org_role on publish; outsiders keep 409 namespace_taken). Owners add and remove members and change roles; the last owner cannot be removed,
demoted or leave (409 last_owner). Leaving (or being removed) takes nothing back: the packages a member published stay theirs. Four-eyes review
treats every owner and admin of the org as a publisher of the org's packages. Routes (registry.publish unless noted):
POST /api/registry/orgs ({ handle, displayName, url?, avatarUrl? }) → 201 RegistryOrg (the caller's publisher becomes the owner; 409 handle_taken);
GET /api/registry/orgs/mine → RegistryOrgMembership[]; GET /api/registry/orgs/:id → RegistryOrgDetail (members or users.manage);
PUT /api/registry/orgs/:id ({ handle?, displayName?, url?, avatarUrl? }) → RegistryOrg (owner or admin);
POST /api/registry/orgs/:id/members ({ handle, role? }) → RegistryOrgMember (owner); PUT /api/registry/orgs/:id/members/:publisherId ({ role }) → RegistryOrgMember (owner);
DELETE /api/registry/orgs/:id/members/:publisherId → 204 (owner); POST /api/registry/orgs/:id/leave → 204 (any member);
POST /api/registry/orgs/:id/namespaces/:name/transfer → Namespace (the namespace's owner publisher who is also an org owner, or users.manage);
PUT /api/registry/orgs/:id/namespaces/:name/maintainers ({ maintainers: publisherId[] }) → Namespace (owner or admin).
Reviewers (users.manage): GET /api/registry/orgs?q=&page=&limit= → Paginated<RegistryOrg>; POST /api/registry/orgs/:id/verify ({ verified }) → RegistryOrg.
Public: GET /registry/v1/orgs/:handle → { org, members, packages } (the packages of every namespace the org owns). POST /api/registry/namespaces/:name/transfer
also takes
ownerOrgIdinstead ofownerPublisherId. Audit: registry.org_create, registry.org_update, registry.org_member_add, registry.org_member_role,registry.org_member_remove, registry.org_leave, registry.org_verify, registry.namespace_transfer, registry.namespace_maintainers.
GET /registry/v1/packages/:name answers
org(RegistryOrg | null) next topublisher; the public blocks show "by <org>" linking /registry/orgs/<handle>.Public JSON (no auth, cached 60 s, CORS *): GET /registry/v1/index.json (the signed index the module system consumes:
modulesholds listed moduleversions only, with sha256, tier, manifest and an absolute tarball URL;
kits,themesandbrandshold the other kinds in the same shape withkind;signed with REGISTRY_SIGNING_KEY (private key PEM path) when set;
generatedAtandregistry(the base URL) are part of the signed document),GET /registry/v1/packages?q=&kind=&tier=&sort=downloads|updated|name&page=&limit= → Paginated<RegistryPackage> (unreviewed trusted modules are
left out of search and listing;
?include=unreviewedlists them), GET /registry/v1/packages/:name → { package, versions: PackageVersion[], advisories },GET /registry/v1/packages/:name/:version/download → the tarball (counts a download, 1 per IP per version per day; yanked and rejected versions 410/404),
GET /registry/v1/packages/:name/:version/readme → markdown, GET /registry/v1/packages/:name/:version/files/<path> → one file out of the stored tarball
(listed and pending versions; only the manifest's screenshots plus README.md, CHANGELOG.md, manifest.json and kit.json; typed by extension, cached
immutable; anything else 404), GET /registry/v1/advisories?package= → Advisory[], GET /registry/v1/publishers/:handle →
{ publisher, packages }, GET /registry/v1/stats → RegistryStats. Package names with a namespace are written as @ns/name in the path (URL-encoded or not).
Theme previews: GET /registry/<name>/preview and /registry/<name>/preview/<page>[?version=] (registry host only) render a listed theme's kit
pages with its tokens (theme.json in the tarball in any format the theme import reads, or
themein kit.json) on an in-memory scratch site(an LRU of 5 warm sites per theme version) as a self-contained page: no admin, forms made inert, internal links kept inside the preview,
X-Robots-Tag: noindex, cached 10 minutes under the registry tags. 404 for modules, kits, unknown names, versions and pages. The ThemeGalleryand RegistryPackage blocks link "Preview" there. No screenshot is generated server-side: the publisher's screenshots stay.
Agent-readable: GET /registry/v1/packages/:name/manifest.json → AgentPackageManifest ({ name, kind, latest, install: { command }, capabilities,
config: { settings schema when the manifest declares
settingsSchema}, docs: { readme, homepage } }). A module's install command isPOST /api/modules/install { source: 'registry', name }; a kit's or theme's is POST /api/kits/install-from-registry { name, version?, withSampleContent? }
(users.manage; the site downloads the tarball from REGISTRY_URL's index, verifies its sha256 and runs the kit installer like POST /api/kits/:key/install
→ KitInstallReport; 404 not_found for a name or version the index lacks) or
spillway kit install <name>[@version] --registry <url> [--sample].Deprecated packages stay in the index with
deprecated: trueand the note; yanked versions leave the index (an install pinned to one fails with not_found)and advisories are listed in the package response and the agent manifest.
CLI:
spillway publish [dir] --registry <url> --token <spw_...>(packs with the tar writer, excludes node_modules/.git/tests, prints the checks result),
spillway registry keygen|signalready exist;spillway registry serveisREGISTRY_HOST=1 spillway start.Sign-in with GitHub: SsoProtocol gains 'github' (OAuth 2 authorization code with the user:email scope; identity = GitHub user id, email from
/user/emails primary verified); provider config: clientId, clientSecret (
issuermay name a GitHub Enterprise origin); JIT provisioning creates a userwith the
viewerrole plusregistry.publish; a Publisher row is created on first publish or by POST /api/registry/publishers/me ({ handle, displayName, url }).Env: REGISTRY_HOST=1 mounts the registry (otherwise GET /registry/v1/* is 404), REGISTRY_SIGNING_KEY (path to an ed25519 private key PEM from
spillway registry keygen), REGISTRY_STORAGE (tarball directory, default <DATA_DIR>/registry).
const REGISTRY_PACKAGE_NAME_RE = /^(@[a-z0-9][a-z0-9-]*\/)?[a-z0-9][a-z0-9-]{0,63}$/
REGISTRY_HANDLE_RE
Constant.
const REGISTRY_HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,38}$/
REGISTRY_TARBALL_MAX_BYTES
Constant.
const REGISTRY_TARBALL_MAX_BYTES = 20 * 1024 * 1024
Importer
ImportJobStatus
Type alias.
The domain importer (docs/importer.md): point Spillway at a domain and it crawls the site, extracts every page, clusters pages into content
types, converts bodies to blocks, extracts a theme and lands everything as drafts. All routes require
imports.manageand are scoped to therequest's site. Agent A (crawl) routes, routes/imports.ts:
POST /api/imports { url, options?: Partial<ImportJobOptions> } → 201 ImportJob (queued; the ticker runs it within a tick);
GET /api/imports?page=&limit= → Paginated<ImportJob>; GET /api/imports/:id → ImportJob; POST /api/imports/:id/cancel → ImportJob;
DELETE /api/imports/:id → 204 (rows and fetched files; landed entities stay);
GET /api/imports/:id/pages?status=&cluster=&q=&page=&limit= → Paginated<ImportPageSummary> (ImportPage without mainHtml, text and blocks;
statusis ok|redirect|error,clustera cluster id ornone,qmatches url and title);GET /api/imports/:id/pages/:pid → ImportPage; GET /api/imports/:id/pages/:pid/raw → text/html of the fetched source, served with
Content-Security-Policy: sandboxso nothing in it runs;GET /api/imports/:id/assets → ImportAsset[]; GET /api/imports/:id/redirects → ImportRedirect[]; GET /api/imports/:id/menus → ImportMenu[];
POST /api/imports/:id/recrawl { pageIds?: string[] } → ImportJob (requeues the listed pages, or the whole site when omitted);
GET /api/imports/:id/events → { items: string[] } (the last 200 log lines).
Audit actions: import.create, import.cancel, import.delete, import.recrawl.
Agent B (classify, convert, land) routes, routes/imports-plan.ts (same mount, imports.manage, the job must belong to the request's site):
POST /api/imports/:id/classify → { job: ImportJob, clusters: ImportCluster[], notes: string[], usedAi: boolean } (clusters pages by urlPattern +
skeletonHash with a shape-similarity merge, one TypeProposal per cluster: deterministic from headings, dates, images, breadcrumbs, JSON-LD and
adapter hints, refined by the
importAI task when configured and the job'soptions.useAiis not false, and strictly validated; the one-off pages form a cluster mapped topageand approved by default; status classified; a re-run keeps decisions and approved proposals for clusters whose id survives);
GET /api/imports/:id/clusters → ImportCluster[];
PUT /api/imports/:id/clusters/:cid { decision?, label?, targetTypeKey?, mergedIntoId?, proposal? } → ImportCluster (
mergedneeds mergedIntoId;a proposal must have a title field, field types from the import list, parseable sources and an existing
existingTypeKey);POST /api/imports/:id/convert { clusterIds? } → { job, converted, skipped, averageScore, notes } (fields per source rule, body → blocks with the
existing block library, ImportQuality per page; status planned);
GET /api/imports/:id/pages/:pid/compare → { original: { html, text, images, links }, converted: { blocks, html (server-rendered), text }, quality };
POST /api/imports/:id/land { pageIds?, clusterIds?, publish? } → ImportReport & { summary: { entities, types, assets, redirects, redirectsDropped, menus, sharedBlocks,
workstreamId, notes } } (
redirectsDroppedcounts source redirects to other hosts left out underoptions.offsiteRedirects: 'drop', the default) (types for approved proposals, draft entities in the job's workstream at the original paths with 301s for moved ones,media through downloadAsset and re-linked, the source's redirects, menus as
import-<key>, shared blocks for fragments on ≥ 3 pages; idempotent;
publishapplies only outside a workstream for callers with content.publish; status landed);GET /api/imports/:id/report → ImportReport.
AI usage estimate (routes/imports.ts, imports.manage; docs/importer.md#ai-usage). The importer's only AI step is the type proposals of
classify: oneimporttask run per classification that has at least one cluster besides the one-off pages, sending every such cluster inone request; the run makes one provider call, two when the first answer is not valid JSON. Landing, conversion and the theme make none.
POST /api/imports/estimate { url, options?: Partial<ImportJobOptions> } → ImportAiEstimate (stage
before_crawl): reads robots.txt and thesitemap (through the SSRF guard, at most 10 sitemap documents, cached for 5 minutes per source and scope; nothing is stored) and turns the
page cap and the sitemap's URL patterns into a range of clusters, calls and tokens. Rate limited per user. Writes nothing.
GET /api/imports/:id/estimate → ImportAiEstimate (stage
after_crawl, the job's ownuseAi): clusters the crawled pages the way classify willand measures the prompt it would send, so the range narrows to the real cluster count before Run classification spends it.
The cap figures come from the AI layer's metering (
ai_log, Settings → AI monthly token cap); pricing is not known to it, so the estimate isin calls and tokens and the cap's share, never in money. Exceeding the cap is a warning: the existing cap enforcement is unchanged.
Field source rules (TypeProposal.fields[].source): title | h1 | meta:<name> | date:published | date:modified | body (layout) | text | image:first |
breadcrumb:<n> | selector:<css> | og:<property> | jsonld:<property> | url | path. Audit: import.classify, import.cluster.update, import.convert, import.land.
type ImportJobStatus = 'queued' | 'crawling' | 'crawled' | 'classifying' | 'classified' | 'converting' | 'planned' | 'landing' | 'landed' | 'failed' | 'cancelled'
ImportJobOptions
Interface.
| Member | Type | Notes |
|---|---|---|
maxPages | number | default 200, cap 5000 |
rateLimitMs | number | default 500 |
respectRobots | boolean | default true |
include | string[] | glob (/news/**) or re: regex on the path; empty means everything |
exclude | string[] | |
followSubdomains | boolean | default false |
useSitemap | boolean | default true |
adapters | `('drupal' | 'wordpress')[]` |
locale? | string | |
workstream | boolean | default true: land inside a workstream named after the job |
offsiteRedirects? | `'keep' | 'drop'` |
useAi? | boolean | Use the site's AI provider for type proposals (the import task) when one is configured; default true. False keeps classification heuristic for this job: no AI call is made. |
ImportStats
Interface.
| Member | Type | Notes |
|---|---|---|
discovered | number | |
fetched | number | |
failed | number | |
skipped | number | |
bytes | number | |
assets | number | |
clusters | number | |
converted | number | |
landed | number | |
startedAt? | string | |
finishedAt? | string |
ImportDetected
Interface.
What the adapters learned about the source (job.detected). The optional members are filled when an API is exposed.
| Member | Type | Notes |
|---|---|---|
platform | `'drupal' | 'wordpress' |
version? | string | |
generator? | string | |
apis | string[] | |
types? | { key: string; label: string; fields: string[]; count?: number }[] | Content types the source declares (Drupal node bundles, WordPress post types) with the field names seen on a sample item. |
taxonomies? | { key: string; label: string; terms: string[] }[] | Vocabularies/taxonomies with their term names. |
authors? | string[] | Author display names. |
ImportJob
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
sourceUrl | string | |
status | ImportJobStatus | |
options | ImportJobOptions | |
stats | ImportStats | |
detected | ImportDetected | |
workstreamId | `string | null` |
error | `string | null` |
createdBy | string | |
createdAt | string | |
updatedAt | string |
ImportPageMeta
Interface.
| Member | Type | Notes |
|---|---|---|
description? | string | |
canonical? | string | |
lang? | string | |
og | Record<string, string> | |
jsonLd | unknown[] | |
published? | string | |
modified? | string | |
author? | string | |
hints? | Record<string, unknown> | Per-page hints from the platform adapters: { platform, type, id, terms?, author? } (Drupal node type and nid, WordPress post type and id). |
ImportPage
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
jobId | string | |
url | string | |
path | string | |
finalUrl | string | |
statusCode | number | |
contentType | string | |
fetchedAt | string | |
depth | number | |
title | string | |
meta | ImportPageMeta | |
headings | { level: number; text: string }[] | |
mainHtml | string | |
text | string | |
wordCount | number | |
skeletonHash | string | |
urlPattern | string | |
links | { href: string; text: string; internal: boolean }[] | |
images | { src: string; alt: string; width?: number; height?: number }[] | |
videos | string[] | |
forms | { action: string; method: string; fields: { name: string; type: string; label: string; required: boolean; options?: string[] }[] }[] | |
breadcrumbs | string[] | |
clusterId | `string | null` |
typeKey | `string | null` |
fields | `Record<string, unknown> | null` |
blocks | `unknown | null` |
entityId | `string | null` |
quality | `ImportQuality | null` |
error | `string | null` |
ImportPageSummary
Type alias.
type ImportPageSummary = Omit<ImportPage, 'mainHtml' | 'text' | 'blocks'>
ImportQuality
Interface.
| Member | Type | Notes |
|---|---|---|
score | number | |
issues | `{ code: string; severity: 'info' | 'warn' |
textRetained | number | |
imagesRetained | number | |
linksRetained | number |
TypeProposal
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
name | string | |
description | string | |
reason | string | |
fields | `{ key: string; label: string; type: string /* existing field type names /; required: boolean; source: string / how it is filled: 'title' | 'meta:description' |
pathPattern | string | |
sampleUrls | string[] | |
existingTypeKey? | string | when it matches a type already on the site |
ImportCluster
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
jobId | string | |
label | string | |
skeletonHash | string | |
urlPattern | string | |
pageCount | number | |
sampleUrls | string[] | |
proposal | `TypeProposal | null` |
decision | `'pending' | 'approved' |
mergedIntoId | `string | null` |
targetTypeKey | `string | null` |
ImportAsset
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
jobId | string | |
sourceUrl | string | |
kind | `'image' | 'document' |
bytes | number | |
mediaId | `string | null` |
status | `'pending' | 'downloaded' |
error | `string | null` |
contentType? | `string | null` |
ImportRedirect
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
jobId | string | |
sourcePath | string | |
targetPath | string | |
status | number | crawl when the crawler met it, source when the platform's redirect table listed it. |
origin | `'crawl' | 'source'` |
ImportMenu
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
jobId | string | |
key | string | |
label | string | |
items | ImportMenuItem[] |
ImportMenuItem
Interface.
| Member | Type | Notes |
|---|---|---|
label | string | |
href | string | |
children? | ImportMenuItem[] |
ImportReport
Interface.
| Member | Type | Notes |
|---|---|---|
job | ImportJob | |
clusters | ImportCluster[] | |
pages | { total: number; byStatus: Record<string, number>; byType: Record<string, number> } | |
quality | { average: number; below70: number; issuesByCode: Record<string, number> } | |
redirects | number | |
menus | number | |
assets | { downloaded: number; failed: number } | |
theme | { extracted: boolean; tokens?: number } | |
durationMs | number |
ImportThemeExtract
Interface.
| Member | Type | Notes |
|---|---|---|
tokens | ThemeTokens | |
custom | Record<string, string> | |
fonts | `{ family: string; source: 'google' | 'system' |
logo | `{ src: string; alt: string } | null` |
favicon | `string | null` |
palette | `{ color: string; count: number; role?: 'primary' | 'accent' |
sources | string[] | stylesheet urls read |
IMPORT_DEFAULT_OPTIONS
Constant of type ImportJobOptions.
const IMPORT_DEFAULT_OPTIONS = { maxPages: 200, rateLimitMs: 500, respectRobots: true, include: [], exclude: [], followSubdomains: false, useSitemap: true, adapters: ['drupal', 'wordpress'], workstream: true, offsiteRedirects: 'drop', useAi: true }
ImportAiEstimate
Interface.
POST /api/imports/estimate and GET /api/imports/:id/estimate: the AI calls an import will make, before they are made.
| Member | Type | Notes |
|---|---|---|
stage | `'before_crawl' | 'after_crawl'` |
ai | `{ enabled: boolean; configured: boolean; provider: AiProvider | null; model: string |
useAi | boolean | The per-job switch (options.useAi). |
willUseAi | boolean | True when classification will call the model: useAi, AI enabled and configured, and at least one cluster to refine possible. |
pages | `{ cap: number; sitemap: number | null; expected: { min: number; max: number } }` |
clusters | { min: number; max: number } | Clusters sent to the model (the one-off pages cluster is never sent). |
calls | { min: number; max: number } | Provider calls per classification run (each re-run spends the same again). |
tokens | { min: number; max: number } | Input plus output tokens per classification run, estimated at four characters per token. |
cap | `{ monthlyTokenCap: number | null; usedThisMonth: number; remaining: number |
exceedsCap | boolean | True when the AI will be used and tokens.max exceeds the remaining cap: the wizard asks for an explicit confirmation. |
summary | string | One sentence for people: "About 1 AI call (2 at most), roughly 4,000 to 31,000 tokens." |
notes | string[] |
IMPORT_MAX_PAGES
Constant.
const IMPORT_MAX_PAGES = 5000
IMPORT_PAGE_MAX_BYTES
Constant.
const IMPORT_PAGE_MAX_BYTES = 10 * 1024 * 1024
IMPORT_ASSET_MAX_BYTES
Constant.
const IMPORT_ASSET_MAX_BYTES = 50 * 1024 * 1024
IMPORT_TOTAL_MAX_BYTES
Constant.
const IMPORT_TOTAL_MAX_BYTES = 2 * 1024 * 1024 * 1024
IMPORT_TIME_BUDGET_MS
Constant.
const IMPORT_TIME_BUDGET_MS = 60 * 60 * 1000
IMPORT_CONCURRENCY
Constant.
const IMPORT_CONCURRENCY = 4
IMPORT_MIN_RATE_LIMIT_MS
Constant.
The crawler never starts requests to one host closer together than this, whatever
rateLimitMssays (at most 10 a second).
const IMPORT_MIN_RATE_LIMIT_MS = 100
IMPORT_LOG_LINES
Constant.
const IMPORT_LOG_LINES = 200
Importer: theme, admin and CLI (Agent C)
ImportThemeResult
Interface (extends ImportThemeExtract).
routes/imports-theme.ts (same mount, imports.manage):
POST /api/imports/:id/theme/extract → ImportThemeResult: reads the stylesheets the crawled pages link to (through the SSRF guard, at most
10 sheets of 2 MB from the site's own hosts and the CDN hosts the pages name), collects custom properties, colour literals with the
selectors they are used in, font stacks, @font-face and Google Fonts links, the spacing scale, radii, the logo and the favicon, and maps them
onto ThemeTokens with a confidence per token (0..1). Stored in import_theme; extracting again replaces it.
GET /api/imports/:id/theme → ImportThemeResult (404 until extracted).
POST /api/imports/:id/theme/apply ImportThemeApplyRequest → ImportThemeResult: merges the given (or the extracted) tokens into the brand
draft (started from the published brand when there is none; values the draft would refuse are left out; audit brand.draft_save +
import.theme_apply); with logoAsMedia the logo is fetched, added to the media library (logoMediaId) and set as the draft's logo. The live
theme is untouched until the draft is published under Structure → Brand.
Admin: Structure → Import (/admin/import): the jobs list, New import, and the job wizard with the steps Crawl, Types, Convert, Theme, Land.
CLI: spillway import <url> [--max-pages N] [--rate-limit ms] [--no-robots] [--no-ai] [--classify] [--convert] [--land] [--theme] [--json] runs the job
in-process and streams progress; it prints the AI usage estimate before the crawl and, with --classify, again before classifying; --no-ai turns
the job's AI type proposals off; --classify approves every proposal, --land lands the drafts, --theme extracts the theme and writes it to the brand draft.
| Member | Type | Notes |
|---|---|---|
confidence | Record<string, number> | Token key (the seven named tokens and the custom names) → 0..1. |
spacing | string[] | The spacing scale found in margin, padding and gap values (px, ascending); also emitted as custom tokens space-xs … space-4xl. |
radii | string[] | Border radius values seen, most frequent first. |
warnings | string[] | |
extractedAt | string | |
applied | boolean | |
appliedAt | `string | null` |
logoMediaId | `string | null` |
ImportThemeApplyRequest
Interface.
| Member | Type | Notes |
|---|---|---|
tokens? | Partial<Pick<ThemeTokens, ThemeTokenName>> | |
custom? | Record<string, string> | |
logoAsMedia? | boolean |
ImportPageCompare
Interface.
GET /api/imports/:id/pages/:pid/compare (routes/imports-plan.ts) as the admin's Compare drawer reads it.
| Member | Type | Notes |
|---|---|---|
original | { html: string; text: string; images: string[]; links: string[] } | |
converted | { blocks: unknown; html: string; text: string } | |
quality | `ImportQuality | null` |
SCIM 2.0 provisioning (RFC 7643, RFC 7644)
ScimToken
Interface.
Server at /scim/v2 (routes/scim.ts). Authentication:
Authorization: Bearer scim_<prefix>_<secret>, one token per SSO provider, separate fromuser API tokens (no user, no permission set: the token acts as the provider, audited as actor "scim:<slug>"). Only sha256(secret) is stored.
Admin (users.manage, browser session only, like /api/tokens):
GET /api/sso/providers/:id/scim → ScimStatus (base URL, the token's public record, last sync, the last 20 SCIM audit events)
POST /api/sso/providers/:id/scim-token → ScimTokenCreateResponse (201; rotates: the previous token stops working, the secret is shown once)
DELETE /api/sso/providers/:id/scim-token → 204 (revokes without replacement)
Resources (every request and response is application/scim+json; application/json is accepted on input, anything else is 415):
GET /scim/v2/ServiceProviderConfig, /ResourceTypes, /Schemas (no authentication; static)
GET /scim/v2/Users?filter=&startIndex=&count= filter:
userName eq "x",emails.value eq "x",emails[type eq "work"].value eq "x",
externalId eq "x",id eq "x"(onlyeq; anything else is 400 scimType invalidFilter). 1-based startIndex, count ≤ SCIM_PAGE_MAX.GET /scim/v2/Users/:id, POST /scim/v2/Users (201; 409 uniqueness when the userName/email exists), PUT /scim/v2/Users/:id (replace),
PATCH /scim/v2/Users/:id (PatchOp: add/replace/remove on active, userName, externalId, name(.givenName|.familyName|.formatted), displayName,
emails / emails[type eq "work"].value, groups; Entra's "True"/"False" strings and capitalised ops are accepted),
DELETE /scim/v2/Users/:id → 204 and the account is deactivated (never deleted: content keeps its author).
GET /scim/v2/Groups (filter: displayName eq, externalId eq, id eq), GET /Groups/:id, POST /Groups (201; 409 on a duplicate displayName within the
provider), PUT /Groups/:id, PATCH /Groups/:id (members add / remove / replace,
members[value eq "id"], displayName, externalId), DELETE /Groups/:id.Mapping: userName and the primary email are the user's email (userName wins when it is an address); name → the display name; externalId is stored on
the user; active ↔ User.active. A user is visible to a provider's token when the provider owns the account (authProvider = slug) or it is a local
password account (which a mutation links to the provider); accounts owned by another provider are 404. Group membership drives roles through
the provider's groupRoleMap: the union of the roles of the user's mapped groups, or defaultRoles when none matches, replaces the user's global roles
(the same rule SSO sign-in applies); a membership removal or a group deletion recomputes them. Deactivation ends every session and revokes
every API token of the user and refuses password and SSO sign-in (
account_disabled);active: truerestores access.Errors: { schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], status: '400', scimType?: 'invalidFilter' | 'invalidPath' | 'invalidValue' |
'invalidSyntax' | 'uniqueness' | 'mutability' | 'noTarget' | 'tooMany', detail }. 401 for a missing or bad token, 404 unknown resource,
413 above SCIM_BODY_LIMIT, 415 wrong content type, 429 with Retry-After above SCIM_RATE_LIMIT_PER_MINUTE requests per token and minute.
Audit: every mutation writes a row with actor "scim:<slug>" (scim.user.create|update|activate|deactivate|roles, scim.group.create|update|members|delete);
token rotation and revocation write scim.token.rotate|revoke with the admin as actor.
| Member | Type | Notes |
|---|---|---|
id | string | |
providerId | string | |
prefix | string | |
createdBy | `string | null` |
createdAt | string | |
lastUsedAt | `string | null` |
ScimTokenCreateResponse
Interface.
| Member | Type | Notes |
|---|---|---|
token | ScimToken | |
secret | string |
ScimEvent
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
action | string | |
actorId | `string | null` |
targetType | string | |
targetId | string | |
createdAt | string | |
detail | unknown |
ScimStatus
Interface.
| Member | Type | Notes |
|---|---|---|
baseUrl | string | |
token | `ScimToken | null` |
lastSyncAt | `string | null` |
events | ScimEvent[] |
SCIM_TOKEN_PREFIX
Constant.
const SCIM_TOKEN_PREFIX = 'scim'
SCIM_BASE_PATH
Constant.
const SCIM_BASE_PATH = '/scim/v2'
SCIM_RATE_LIMIT_PER_MINUTE
Constant.
const SCIM_RATE_LIMIT_PER_MINUTE = 600
SCIM_BODY_LIMIT
Constant.
const SCIM_BODY_LIMIT = 1024 * 1024
SCIM_PAGE_DEFAULT
Constant.
const SCIM_PAGE_DEFAULT = 100
SCIM_PAGE_MAX
Constant.
const SCIM_PAGE_MAX = 500
SCIM_EVENTS_SHOWN
Constant.
const SCIM_EVENTS_SHOWN = 20
Headless front ends: the public render API, webhooks and the adapters (Phase 4)
PUBLIC_API_PREFIX
Constant.
Sites that render Spillway content elsewhere (Astro, Next.js, any fetch client) read these endpoints with @spillway-cms/client.
All are GET, JSON, under /api/public, need no auth for published content, and carry
Cache-Tag: <tags>so a CDN or anon-demand revalidation can purge by the same tags the in-process page cache uses (docs/adapters.md).
GET /api/public/render/<path>?token=&preview=1 → PublicRender: the entity at the public path (locale prefix honoured,
/is the home page), its published snapshot with every block's server-resolved props (Collection items, Form definition,
Image/Hero media, Shared blocks, Embed hosts) and author HTML sanitised, the SEO head data, the resolved menus, the site and
the cache tags. Media and form URLs are absolute (the Spillway origin). 404 for anything that is not live.
Preview: X-Spillway-Workstream: <id or key> (or ?workstream=) renders the branch version for a bearer token with
content.view_drafts or a share-link ?token=; ?preview=1 renders the latest revision of any status for such a token.
Previews answer with Cache-Control: no-store and X-Spillway-Cache: BYPASS; live pages are cached per path with their tags
(X-Spillway-Cache: HIT|MISS) and sent with the public Cache-Control.
GET /api/public/site → PublicSite (the visitor-safe part of the settings plus the theme as CSS).
GET /api/public/menus/:key?locale=&path= → PublicMenu, items resolved to links the way the server shell renders them.
GET /api/public/paths?type=&locale= → { items: PublicPath[] }: every published path (for getStaticPaths / generateStaticParams).
GET /api/public/entities/:type/:idOrSlug?locale= → Entity (published snapshot; 404 otherwise).
GET /api/public/collections/:key/items?page=&limit=&locale=&q=&filters=<json CollectionFilter[]> → Paginated<CollectionItem>,
the collection's own filters plus the extra ones (max 20).
GET /api/public/media/:id → MediaItem with absolute URLs.
The site is the request's site (Host header); a bearer token may pick another with X-Spillway-Site, as everywhere.
Webhooks: a notification rule with channel { kind: 'webhook', url, secret } on
entity.published,entity.unpublishedor
entity.deletedposts WebhookPayload (JSON) with X-Spillway-Event and X-Spillway-Signature: sha256=<HMAC-SHA256 of the body>through the outbox (retries, SSRF guard);
data.entity.cacheTagsnames what to purge anddata.entity.publicPaththe page.
const PUBLIC_API_PREFIX = '/api/public'
PREVIEW_COOKIE
Constant.
The cookie the adapters' preview helpers read and set:
<workstream id>:<share token>(or just a token for the main line).
const PREVIEW_COOKIE = 'spillway_preview'
PREVIEW_TOKEN_QUERY
Constant.
The query parameter a share link carries; the public render endpoint accepts it too.
const PREVIEW_TOKEN_QUERY = 'token'
PublicSite
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
key? | string | |
name | string | |
tagline? | string | |
origin | string | The Spillway origin pages, media and forms live at (BASE_URL, else the site's baseUrl, else the request origin). |
baseUrl? | string | |
defaultLocale | string | |
locales | string[] | |
localePrefixDefault? | boolean | |
homePageId | `string | null` |
theme | ThemeTokens | |
themeCss | string | The theme tokens as a :root{...} rule, what the server inlines ahead of the block CSS. |
seo | { titleSuffix?: string; defaultDescription?: string; ogImageUrl?: string } |
PublicMenuItem
Interface.
| Member | Type | Notes |
|---|---|---|
label | string | |
href | string | |
newTab? | boolean | |
current? | boolean | |
children? | PublicMenuItem[] |
PublicMenu
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
items | PublicMenuItem[] |
PublicSeo
Interface.
| Member | Type | Notes |
|---|---|---|
title | string | |
description? | string | |
canonical? | string | |
ogImage? | string | |
noindex | boolean | |
alternates | { hreflang: string; href: string }[] | |
jsonLd | Record<string, unknown>[] |
PublicRender
Interface.
| Member | Type | Notes |
|---|---|---|
entity | Entity | |
type | `Pick<ContentType, 'key' | 'label' |
path | string | The public path (locale prefix included) and locale of what was rendered. |
locale | string | |
data | `PuckData | null` |
richHtml | Record<string, string> | Rich text fields rendered to sanitised HTML (internal links resolved, images responsive). |
media | Record<string, MediaItem> | Media items referenced by media fields, by id, with absolute URLs. |
seo | PublicSeo | |
menus | { main?: PublicMenuItem[]; footer?: PublicMenuItem[] } | |
cacheTags | string[] | |
site | PublicSite | |
preview | boolean | |
queryDependent? | boolean | True when a block read the query string (exposed filters, calendar): cache per query, briefly, like the server does. |
PublicPath
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
type | string | |
title | string | |
path | string | |
locale | string | |
updatedAt | string | |
publishedAt | `string | null` |
PublicPathsResponse
Interface.
| Member | Type | Notes |
|---|---|---|
items | PublicPath[] |
WebhookEntity
Interface.
What a webhook notification rule posts (see the Notifications contract above for the headers).
| Member | Type | Notes |
|---|---|---|
id | string | |
type | string | |
title | string | |
path | `string | null` |
publicPath | `string | null` |
locale | string | |
status | string | |
authorId | string | |
adminUrl | string | |
cacheTags | string[] |
WebhookPayload
Interface.
| Member | Type | Notes |
|---|---|---|
event | NotificationEvent | |
at | string | |
subject | string | |
site | `{ name: string; baseUrl: string | null }` |
data | `{ entity?: WebhookEntity; actor?: { id: string; name: string } | null; test?: boolean; [key: string]: unknown }` |
Translation management systems (TMS)
TmsProvider
Type alias.
Every text, textarea and richtext field of an entity and every translatable string of its Puck layout is one XLIFF unit with a stable id:
field:<key>(field:<key>.<i>for a multiple field) orblock:<blockId>:<prop path>(block:Cards-1:cards.0.title; nested slot blocksget their own id). Rich text keeps its inline markup as <pc> (paired tags) and <ph> (void tags) with the original tags in <originalData>.
Importing a translated file writes the target entity of the translation group in the file's trgLang as a DRAFT (created with mode
copywhen it does not exist yet; the published revision is never touched) and reports the applied, skipped and unmatched units.
Settings (per site, stored in
translation_setting, secrets encrypted with the AI secrets helper): GET /api/i18n/tms/settings → TmsSettings(secrets masked as '**'), PUT with TmsSettingsInput (site.settings; the mask keeps the stored value, null clears), POST /api/i18n/tms/test
→ TmsTestResult. Providers:
none,xliff(manual exchange: download a zip of XLIFF files, upload the translated ones),crowdin(REST API v2),
phrase(Phrase TMS / Memsource API),smartling(Files API).Jobs (content.translate): POST /api/i18n/tms/jobs { entityIds, targetLocales, due? } → 201 TranslationJob (status requested, or in_progress
for API providers); GET /api/i18n/tms/jobs?status= → Paginated<TranslationJob>; GET /api/i18n/tms/jobs/:id; POST .../pull downloads every
target and applies drafts (→ applied, with
report; a provider failure is 502tms_errorwith the provider's message, the manualprovider answers 400); POST .../cancel; GET .../xliff (manual provider: a zip of
<entityId>.<locale>.xlf),POST .../xliff (manual provider: one XLIFF file or a zip of them, body or multipart
file). POST /api/i18n/tms/webhook/:provider is public(rate limited): accepted with
X-Spillway-Signature: sha256=<hmac-sha256(body, webhookSecret)>orX-Webhook-Secret: <webhookSecret>,marks the jobs the payload names (or every in-progress job of the provider)
readyand queues atranslation.readynotification.The ticker polls in-progress jobs of API providers every 10 minutes and does the same. Every mutation writes an audit row (
translation.*).
type TmsProvider = 'none' | 'xliff' | 'crowdin' | 'phrase' | 'smartling'
TMS_PROVIDERS
Constant of type TmsProvider[].
const TMS_PROVIDERS = ['none', 'xliff', 'crowdin', 'phrase', 'smartling']
TmsCredentials
Interface.
Write-only credentials:
token(Crowdin personal access token, Phrase API token) oruserId+secret(Smartling user identifier and secret).
| Member | Type | Notes |
|---|---|---|
token? | string | |
userId? | string | |
secret? | string |
TmsSettings
Interface.
GET /api/i18n/tms/settings. Secrets come back as '**' when set and are absent otherwise;
configuredis true when the provider can be used.
| Member | Type | Notes |
|---|---|---|
provider | TmsProvider | |
projectId | `string | null` |
baseUrl | `string | null` |
credentials | TmsCredentials | |
webhookSecret | `string | null` |
configured | boolean | |
webhookUrl | `string | null` |
updatedAt | `string | null` |
TmsSettingsInput
Interface.
PUT /api/i18n/tms/settings. Secrets: a string sets, the mask keeps, null clears.
| Member | Type | Notes |
|---|---|---|
provider? | TmsProvider | |
projectId? | `string | null` |
baseUrl? | `string | null` |
credentials? | `{ token?: string | null; userId?: string |
webhookSecret? | `string | null` |
TmsTestResult
Interface.
| Member | Type | Notes |
|---|---|---|
ok | boolean | |
message | string | |
provider | TmsProvider |
TmsStatus
Interface.
GET /api/i18n/tms/status (content.view_drafts): what editors need before sending, without the settings themselves.
| Member | Type | Notes |
|---|---|---|
provider | TmsProvider | |
name | string | |
configured | boolean | |
manual | boolean |
TMS_PROVIDER_NAMES
Constant of type Record<TmsProvider, string>.
const TMS_PROVIDER_NAMES = { none: 'None', xliff: 'XLIFF files (manual)', crowdin: 'Crowdin', phrase: 'Phrase TMS', smartling: 'Smartling' }
TranslationJobStatus
Type alias.
type TranslationJobStatus = 'requested' | 'in_progress' | 'ready' | 'applied' | 'failed' | 'cancelled'
TRANSLATION_JOB_STATUSES
Constant of type TranslationJobStatus[].
const TRANSLATION_JOB_STATUSES = ['requested', 'in_progress', 'ready', 'applied', 'failed', 'cancelled']
TranslationJobEntity
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
type | string | |
title | string | |
locale | string | |
units | number |
XliffImportReport
Interface.
What an import of one target file did: applied unit ids, skipped ones with the reason, and ids the entity does not have.
| Member | Type | Notes |
|---|---|---|
applied | string[] | |
skipped | { id: string; reason: string }[] | |
unmatched | string[] |
TranslationJobFileReport
Interface (extends XliffImportReport).
| Member | Type | Notes |
|---|---|---|
entityId | string | |
locale | string | |
targetEntityId | `string | null` |
appliedAt | string |
TranslationJobReport
Interface.
| Member | Type | Notes |
|---|---|---|
files | TranslationJobFileReport[] | One entry per (entity, locale) file that was applied, newest last. |
progress? | Record<string, number> | Provider progress per locale when known (0 to 100). |
message? | string | The last message from the provider or the last error. |
TranslationJob
Interface.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
provider | TmsProvider | |
externalId | `string | null` |
externalUrl | `string | null` |
entities | TranslationJobEntity[] | |
sourceLocale | string | |
targetLocales | string[] | |
status | TranslationJobStatus | |
report | TranslationJobReport | |
error | `string | null` |
due | `string | null` |
createdBy | string | |
createdAt | string | |
updatedAt | string |
TranslationJobInput
Interface.
POST /api/i18n/tms/jobs. Entities are sent as their translation group's source;
dueis an ISO date handed to the provider.
| Member | Type | Notes |
|---|---|---|
entityIds | string[] | |
targetLocales | string[] | |
due? | `string | null` |
Digital asset management (DAM) connectors
DamProvider
Type alias.
A media item can reference an asset in a DAM instead of a local file:
MediaItem.sourcenames the provider, the asset's id in it,the asset's public delivery URL, a version and the provider's metadata. Its
urlstays the stable/media/<id>/<filename>path(the public file route answers it with a redirect to the delivery URL, transformation parameters translated), so pickers, the
Image and Hero blocks, rich text embeds and media fields keep referring to it the way they refer to local files;
mediaUrl(),
mediaSrcSet()andMediaItem.variantsgive the DAM's own delivery URLs directly, with the provider's transformation syntax forwidth, height, format, quality and the focal crop where the provider supports it (otherwise the original). Rendering never
downloads the bytes; derivatives of external media are URL rewrites, never sharp runs. POST /api/media/:id/copy-local downloads
the asset (through the SSRF guard, under the upload cap) and turns the item into a local one.
Settings (Settings → Media;
SiteSettings.media.dam, PUT /api/site with site.settings): the provider, its credentials (the secretones are write-only: stored encrypted with the instance secret under <DATA_DIR>/ai-secret and echoed back as "**"; the
mask, or a missing value, keeps the stored one) and a default folder (Cloudinary folder, Bynder tag, Brandfolder section) that
narrows searches.
POST /api/media/dam/test ({ provider?, credentials?, defaultFolder? }, else the stored settings) → DamTestResponse (site.settings)
GET /api/media/dam/search?q=&page=&cursor=&kind= → DamSearchResponse (media.upload)
POST /api/media/dam/import (DamImportRequest) → MediaItem (201; 200 with the existing item when the asset was imported before) (media.upload).
Alt text comes from the DAM metadata (Cloudinary context alt/caption, Bynder description, Brandfolder description) unless given.
POST /api/media/:id/copy-local → MediaItem (media.upload): downloads the asset, converts the item to a local file (
sourcegone,derivatives generated with sharp from then on) and evicts every page using it.
POST /api/media/dam/webhook/:provider (public, rate limited; Cloudinary notifications verified with X-Cld-Signature, every provider
also accepts ?token=<webhookSecret> or X-Spillway-Webhook-Token) → DamWebhookResponse { changed: string[], errors: [{ siteId, message }] }.
An updated asset gets a new version (its delivery URLs change) and refreshed dimensions and URL; the pages using it are evicted
through the media:<id> cache tag. Every site configured for the provider is tried; a site whose connector fails is listed in
errorsand the others are still updated. Imports are unique per site and asset (a partial unique index on media).Audit: media.dam_import, media.copy_local, media.dam_webhook (actor null); settings through site.update (credentials masked).
type DamProvider = 'cloudinary' | 'bynder' | 'brandfolder'
DAM_PROVIDERS
Constant of type DamProvider[].
const DAM_PROVIDERS = ['cloudinary', 'bynder', 'brandfolder']
DAM_PROVIDER_NAMES
Constant of type Record<DamProvider, string>.
const DAM_PROVIDER_NAMES = { cloudinary: 'Cloudinary', bynder: 'Bynder', brandfolder: 'Brandfolder' }
MediaSource
Interface.
| Member | Type | Notes |
|---|---|---|
provider | DamProvider | |
externalId | string | The asset's id in the DAM (Cloudinary public_id, Bynder media id, Brandfolder asset id). |
url | string | The asset's public delivery URL as the DAM gave it (the original; transformations are derived from it). |
version? | string | Bumped by the webhook when the asset changes in the DAM, so every delivery URL changes with it. |
metadata? | Record<string, unknown> | Provider details the connector needs later (resource type, attachment id, transform base URL, download URL, ...). |
SiteDam
Interface.
| Member | Type | Notes |
|---|---|---|
provider | `DamProvider | 'none'` |
credentials? | Record<string, string> | Per provider, see DAM_CREDENTIAL_FIELDS; secret fields are write-only. |
defaultFolder? | string |
SiteMedia
Interface.
| Member | Type | Notes |
|---|---|---|
dam? | SiteDam |
DamCredentialField
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
secret | boolean | |
help? | string |
DAM_CREDENTIAL_FIELDS
Constant of type Record<DamProvider, DamCredentialField[]>.
What each provider needs; the admin form is built from this and the server encrypts and masks the
secretones.
const DAM_CREDENTIAL_FIELDS = {
cloudinary: [
{ key: 'cloudName', label: 'Cloud name', secret: false },
{ key: 'apiKey', label: 'API key', secret: false },
{ key: 'apiSecret', label: 'API secret', secret: true, help: 'From the Cloudinary console (Settings → API keys). Also verifies notification signatures.' },
],
bynder: [
{ key: 'domain', label: 'Portal domain', secret: false, help: 'For example acme.bynder.com' },
{ key: 'token', label: 'Permanent token', secret: true, help: 'A permanent token with media:read (Settings → Advanced → Permanent tokens).' },
{ key: 'webhookSecret', label: 'Webho
// …
DamAssetKind
Type alias.
type DamAssetKind = 'image' | 'video' | 'document' | 'other'
DAM_ASSET_KINDS
Constant of type DamAssetKind[].
const DAM_ASSET_KINDS = ['image', 'video', 'document', 'other']
DamAsset
Interface.
An asset as a connector describes it (GET /api/media/dam/search items).
| Member | Type | Notes |
|---|---|---|
externalId | string | |
name | string | |
kind | DamAssetKind | |
mime | `string | null` |
size | `number | null` |
width | `number | null` |
height | `number | null` |
url | string | The public delivery URL of the original. |
thumbnailUrl | `string | null` |
alt | string | Alt text from the DAM's metadata ('' when it has none). |
title | `string | null` |
version | `string | null` |
updatedAt | `string | null` |
metadata | Record<string, unknown> |
DamSearchResponse
Interface.
| Member | Type | Notes |
|---|---|---|
items | DamAsset[] | |
page | number | |
total | `number | null` |
nextCursor | `string | null` |
provider | DamProvider | |
name | string |
DamImportRequest
Interface.
| Member | Type | Notes |
|---|---|---|
externalId | string | |
alt? | string | |
title? | string |
DamTestResponse
Interface.
| Member | Type | Notes |
|---|---|---|
ok | boolean | |
provider | `DamProvider | 'none'` |
name | string | |
message | string |
DamWebhookError
Interface.
| Member | Type | Notes |
|---|---|---|
siteId | string | |
message | string |
DamWebhookResponse
Interface.
| Member | Type | Notes |
|---|---|---|
changed | string[] | |
errors | DamWebhookError[] |
DamDeliveryOptions
Interface (extends MediaUrlOptions).
| Member | Type | Notes |
|---|---|---|
focal? | { x: number; y: number } | |
width? | `number | null` |
height? | `number | null` |
damDeliveryUrl()
Function.
The delivery URL of a DAM asset for the given derivative options, in the provider's transformation syntax (the original when none apply).
function damDeliveryUrl(source: MediaSource, opts: DamDeliveryOptions = {}): string
CDN purge (docs/caching.md)
CdnProvider
Type alias.
The page cache evicts by tag; with a CDN configured under Site settings → Cache (
SiteSettings.cdn), every invalidation alsoreaches the CDN: the hook in core/src/cdn/hook.ts collects the tags for CDN_COALESCE_MS, resolves them per site into tags and
public URLs (entity paths with their locale prefix and translation variants, the home page, the pages this process cached under
the tags, the registry's JSON on a registry host) and queues one purge per site in the
cdn_purgetable. The ticker delivers it(CDN_PURGE_MAX_ATTEMPTS attempts, 30 s, 2 min and 10 min apart) and audits the outcome as
cdn.purge. A request is never blocked.Providers: cloudflare (purge by Cache-Tag on an Enterprise zone when
tagPurgeis set, otherwise by URL in batches of 30 withbackoff on 429), fastly (purge by surrogate key; the response header Surrogate-Key mirrors Cache-Tag), bunny (purge by URL),
generic (a signed JSON POST { site, tags, urls, everything } to any edge; X-Spillway-Signature over the body with
secret).URL providers cannot purge "every page listing this type", so a site-wide tag (site:, type:, list:, menu:, theme, ...) purges the
site's published URLs up to CDN_URL_PURGE_CAP and
everythingbeyond it.Settings (PUT /api/site { cdn }, site.settings): the secret credentials are write-only (encrypted with the instance secret, echoed
back as "**");
zoneIdis the Cloudflare zone, Fastly service or Bunny pull zone id;purgeEverythingOnThemeturns a themeor settings save into a purge of everything;
sMaxAgeandstaleWhileRevalidateset the public pages' Cache-Control.POST /api/site/cdn/test ({ provider?, credentials?, zoneId? } or the stored settings) → CdnTestResponse (site.settings)
POST /api/site/cdn/purge (CdnPurgeRequest) → CdnPurge (202; queued and delivered at once) (site.settings)
GET /api/site/cdn/purges?page=&limit= → Paginated<CdnPurge>, the last CDN_PURGE_KEEP purges of the site (site.settings)
CLI: spillway cdn purge [--all] [--site KEY] [--tag T]... [--url U]...
type CdnProvider = 'cloudflare' | 'fastly' | 'bunny' | 'generic'
CDN_PROVIDERS
Constant of type CdnProvider[].
const CDN_PROVIDERS = ['cloudflare', 'fastly', 'bunny', 'generic']
CDN_PROVIDER_NAMES
Constant of type Record<CdnProvider, string>.
const CDN_PROVIDER_NAMES = { cloudflare: 'Cloudflare', fastly: 'Fastly', bunny: 'Bunny', generic: 'Generic webhook' }
SiteCdn
Interface.
| Member | Type | Notes |
|---|---|---|
provider | `CdnProvider | 'none'` |
credentials? | Record<string, string> | Per provider, see CDN_CREDENTIAL_FIELDS; secret fields are write-only. |
zoneId? | string | Cloudflare zone id, Fastly service id or Bunny pull zone id. |
tagPurge? | boolean | Cloudflare: the zone is on an Enterprise plan, so purges go by Cache-Tag instead of by URL. |
purgeEverythingOnTheme? | boolean | A theme or site settings save purges everything on the CDN (assets and untagged responses included) instead of the tags. |
sMaxAge? | number | Cache-Control s-maxage of public pages in seconds (default CDN_DEFAULT_S_MAXAGE). |
staleWhileRevalidate? | number | Cache-Control stale-while-revalidate of public pages in seconds (default CDN_DEFAULT_STALE_WHILE_REVALIDATE). |
CdnCredentialField
Interface.
| Member | Type | Notes |
|---|---|---|
key | string | |
label | string | |
secret | boolean | |
help? | string |
CDN_CREDENTIAL_FIELDS
Constant of type Record<CdnProvider, CdnCredentialField[]>.
What each provider needs; the admin form is built from this and the server encrypts and masks the
secretones.
const CDN_CREDENTIAL_FIELDS = {
cloudflare: [{ key: 'apiToken', label: 'API token', secret: true, help: 'A token with the Zone.Cache Purge permission on the zone.' }],
fastly: [{ key: 'apiToken', label: 'API token', secret: true, help: 'A token with the purge_select (and purge_all) scope on the service.' }],
bunny: [{ key: 'apiKey', label: 'API key', secret: true, help: 'The account API key (Account → API).' }],
generic: [
{ key: 'url', label: 'Purge URL', secret: false, help: 'Spillway posts { site, tags, urls, everything } as JSON to this https URL.' },
{ key: 'secret', label: 'Signing secret', secret: tr
// …
CDN_ZONE_LABELS
Constant of type Record<CdnProvider, string | null>.
Where the zone, service or pull zone id goes, per provider; generic needs none.
const CDN_ZONE_LABELS = { cloudflare: 'Zone id', fastly: 'Service id', bunny: 'Pull zone id', generic: null }
CdnPurgeRequest
Interface.
| Member | Type | Notes |
|---|---|---|
tags? | string[] | |
urls? | string[] | |
everything? | boolean |
CdnPurgeStatus
Type alias.
type CdnPurgeStatus = 'pending' | 'delivered' | 'failed'
CdnPurgeTrigger
Type alias.
type CdnPurgeTrigger = 'auto' | 'manual' | 'cli'
CdnPurge
Interface.
One queued or finished purge (GET /api/site/cdn/purges).
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
provider | CdnProvider | |
trigger | CdnPurgeTrigger | |
actorId | `string | null` |
tags | string[] | |
urls | string[] | |
everything | boolean | |
status | CdnPurgeStatus | |
attempts | number | |
lastError | `string | null` |
note | `string | null` |
nextAttemptAt | `string | null` |
createdAt | string | |
deliveredAt | `string | null` |
CdnTestResponse
Interface.
| Member | Type | Notes |
|---|---|---|
ok | boolean | |
provider | `CdnProvider | 'none'` |
name | string | |
message | string |
CDN_COALESCE_MS
Constant.
const CDN_COALESCE_MS = 2000
CDN_PURGE_MAX_ATTEMPTS
Constant.
const CDN_PURGE_MAX_ATTEMPTS = 4
CDN_PURGE_KEEP
Constant.
const CDN_PURGE_KEEP = 200
CDN_URL_PURGE_CAP
Constant.
URL providers purge at most this many URLs per invalidation; beyond it the purge becomes
everything.
const CDN_URL_PURGE_CAP = 500
CDN_DEFAULT_S_MAXAGE
Constant.
const CDN_DEFAULT_S_MAXAGE = 60
CDN_DEFAULT_STALE_WHILE_REVALIDATE
Constant.
const CDN_DEFAULT_STALE_WHILE_REVALIDATE = 300
Onboarding: the getting started checklist and the page builder tour
REPO_URL
Constant.
The project's source repository; "Report a problem" in the admin's Help menu opens its issues.
const REPO_URL = 'https://github.com/kwall-llc/spillway-cms'
DOCS_URL
Constant.
The published documentation; the manual is
${DOCS_URL}/getting-started, the guides${DOCS_URL}/guides/<task>.
const DOCS_URL = 'https://spillwaycms.com/docs'
STATUS_PATH
Constant.
The site's status page (built next to this contract; the Help menu links it whether or not the instance serves one yet).
const STATUS_PATH = '/status'
ONBOARDING_CHECKLIST_PREFERENCE
Constant.
Per-user preference keys the onboarding uses (GET/PUT/DELETE /api/me/preferences/:key).
const ONBOARDING_CHECKLIST_PREFERENCE = 'onboarding:checklist'
ONBOARDING_TOUR_PREFERENCE
Constant.
const ONBOARDING_TOUR_PREFERENCE = 'onboarding:tour'
OnboardingTourPreference
Interface.
The value stored under ONBOARDING_TOUR_PREFERENCE: when the tour was last seen and whether it was ever finished.
| Member | Type | Notes |
|---|---|---|
seenAt | string | |
completed | boolean |
OnboardingStepKey
Type alias.
type OnboardingStepKey = 'kit' | 'home' | 'brand' | 'team' | 'domain' | 'seo' | 'ai' | 'tour'
ONBOARDING_STEPS
Constant of type readonly OnboardingStepKey[].
const ONBOARDING_STEPS = ['kit', 'home', 'brand', 'team', 'domain', 'seo', 'ai', 'tour']
OnboardingStep
Interface.
One checklist step: whether it is done, whether it counts toward completion, and a hint the server knows (the home page id, the installed kits).
| Member | Type | Notes |
|---|---|---|
key | OnboardingStepKey | |
done | boolean | |
optional | boolean | |
detail? | `Record<string, string | number |
OnboardingState
Interface.
GET /api/site/onboarding → OnboardingState (site.settings). The checklist state, computed on the server from the site itself: kit: a kit is installed (SiteSettings.content.kits) or "start blank" was chosen (content.onboarding.blank); home: the home page has more than one main-line revision, not counting the one a kit writes when it replaces the untouched starter page; brand (logo and colors): a core theme token differs from DEFAULT_THEME, an extended color or a custom token is set, a brand version was published (Brand → Publish), the identity holds a logo, or the site's media library holds a logo (an item whose title or file name contains "logo"); team: more than one user; domain: the site has a domain or primary domain, or a base URL that is not localhost; seo: a default description or a social image is saved; ai (optional): a provider has a key; tour: the caller has finished the page builder tour (their
onboarding:tourpreference).completeis true when every non-optional step is done;dismissedis the caller's own choice (onboarding:checklistpreference);runtimesays whether the server runs from the Docker image or from source (the domain step links the install docs when from source). POST /api/site/onboarding/dismiss { undo?: boolean } → OnboardingState: hides the checklist for the caller (undo brings it back). Audited as onboarding.dismiss / onboarding.restore. POST /api/site/onboarding/blank → OnboardingState: records that the site starts without a kit (the setup wizard's offer and the checklist's "Start blank"). Audited as onboarding.blank. The tour itself is per user: the admin stores { seenAt, completed } under PUT /api/me/preferences/onboarding:tour; the Help menu's "Replay the tour" deletes it.
| Member | Type | Notes |
|---|---|---|
steps | OnboardingStep[] | |
done | number | Non-optional steps done, and how many there are. |
total | number | |
complete | boolean | |
dismissed | boolean | |
runtime | `'docker' | 'source'` |
version | string | |
kits | string[] | The installed kits by key, for the checklist's first step. |
homePageId | `string | null` |
Backups (docs/install.md "Backups")
BackupStatus
Type alias.
Instance configuration, not site settings: BACKUP_TARGET (
s3://bucket/prefixthrough the S3-compatible client incore/src/backup/s3.ts, with R2, Tigris and S3 credentials from BACKUP_S3_* or AWS_*, or a local directory), BACKUP_SCHEDULE
(
hourly,daily,weekly, each with an optional time:daily@03:30,weekly@sun 04:00,hourly@15), BACKUP_RETENTION_DAYS(default BACKUP_DEFAULT_RETENTION_DAYS; 0 keeps everything) and BACKUP_ENCRYPTION_KEY (AES-256-GCM of the database archive).
A backup is one folder per run under the target: the SQLite file (
spillway.db, orspillway.pgdumpfrom pg_dump onPostgres),
backup.json(version, applied migrations, checksums, encryption, warnings),keys.json(the instance's keyfiles from DATA_DIR: session-secret, sso-secret, ai-secret and a registry signing key kept inside DATA_DIR, without which
stored credentials cannot be decrypted after a restore),
form-uploads.json,modules.jsonand, on a registry host,
registry.json(file trees of DATA_DIR/form-uploads, DATA_DIR/modules and the registry's tarballs, each file stored once bycontent hash under
form-uploads/,modules/andregistry/),blobs.json(the blobs those trees name, for pruning) and
media.json(the media manifest: every item's id, file name, mime, size and sha256, or the DAM reference for items that livein a DAM and are not copied); media files are stored once by content hash under
media/<sha256>and only new hashes areuploaded on later runs. With BACKUP_ENCRYPTION_KEY every member but the media files is AES-256-GCM encrypted; without it the
key files are still included (a backup must restore) and the run carries the
unencrypted_secretswarning.The ticker runs the schedule (jobs/backup.ts), records every run in
backup_run(migration 0034), prunes runs older thanthe retention and emits
backup.failedto the notification rules when a run fails.GET /api/system/backups → BackupsResponse (users.manage): the instance configuration (no credentials) and the last BACKUP_RUN_KEEP runs
POST /api/system/backups/run → BackupRun (users.manage; 201 when it succeeded, 500 with the row when it failed; 400 without BACKUP_TARGET)
CLI: spillway backup --to <s3://bucket/prefix | DIR> [--no-media]; spillway restore <location> [--data-dir DIR] [--media] [--dry-run] [--force]
The restore writes the key files back with mode 0600 and the modules always, and with --media the media and form uploads; it
refuses to replace key files that differ from the backup's unless --force (naming them), and --dry-run lists every write.
type BackupStatus = 'running' | 'ok' | 'failed'
BackupTrigger
Type alias.
type BackupTrigger = 'schedule' | 'manual' | 'cli'
BackupWarning
Type alias.
unencrypted_secrets: the run holds the instance's key files (which decrypt every stored credential) and BACKUP_ENCRYPTION_KEY was not set, so anyone who can read the backup target can read them. Set the key and the next run is encrypted.
type BackupWarning = 'unencrypted_secrets'
BackupRun
Interface.
One row of
backup_run.locationis the run's folder on the target (s3://bucket/prefix/<stamp>or a directory).
| Member | Type | Notes |
|---|---|---|
id | string | |
trigger | BackupTrigger | |
status | BackupStatus | |
startedAt | string | |
finishedAt | `string | null` |
bytes | `number | null` |
location | `string | null` |
error | `string | null` |
mediaUploaded | `number | null` |
mediaTotal | `number | null` |
version | `string | null` |
database | `'sqlite' | 'postgres' |
warnings | BackupWarning[] | What the run would like an operator to know (migration 0035); empty for a clean run. |
BackupSettingsInfo
Interface.
What the Backups card shows about the instance configuration; never a credential or a key.
| Member | Type | Notes |
|---|---|---|
configured | boolean | |
target | `string | null` |
schedule | `string | null` |
retentionDays | number | |
encrypted | boolean | |
nextRunAt | `string | null` |
lastRun | `BackupRun | null` |
BackupsResponse
Interface.
| Member | Type | Notes |
|---|---|---|
settings | BackupSettingsInfo | |
runs | BackupRun[] |
BACKUP_RUN_KEEP
Constant.
const BACKUP_RUN_KEEP = 200
BACKUP_DEFAULT_RETENTION_DAYS
Constant.
const BACKUP_DEFAULT_RETENTION_DAYS = 30
Status (docs/install.md "Status")
StatusDegradedFlag
Type alias.
Every instance answers
GET /status(a small HTML page) andGET /status.json(InstanceStatus) withCache-Control: no-store,no authentication and nothing secret: the version, uptime, database kind, cache and search providers, the ticker's last tick,
the last backup and the degraded flags. STATUS_PAGE=0 turns both off (404). A site that publishes its own page at
/status(the project site does, with a StatusBoard block) is served that page instead;
/status.jsonstays the instance's.The StatusBoard block (packages/blocks) lists other instances: the server fetches each one's
/status.jsonthrough theSSRF-guarded fetch with a 3 s timeout and keeps the answer for 60 s; the 90-day bar comes from
status_check, filled bythe probe job (status/probe.ts) that runs every STATUS_CHECK_INTERVAL_MS on the ticker for STATUS_PROBE_URLS plus every URL a
published StatusBoard block names, and kept for STATUS_CHECK_KEEP_DAYS.
backup_unencrypted: backups run (or are configured) without BACKUP_ENCRYPTION_KEY, so the instance's key files sit in clear on the backup target. Nothing else about the backup is exposed.
type StatusDegradedFlag = 'ticker_stale' | 'backup_failed' | 'backup_unencrypted' | 'redis_down'
InstanceStatus
Interface.
| Member | Type | Notes |
|---|---|---|
status | `'up' | 'degraded'` |
version | string | |
uptimeSeconds | number | |
startedAt | string | |
database | `'sqlite' | 'postgres'` |
cache | `'memory' | 'redis'` |
search | `'sqlite' | 'postgres' |
ticker | `{ enabled: boolean; lastTickAt: string | null; stale: boolean }` |
backup | `{ configured: boolean; lastRunAt: string | null; lastStatus: BackupStatus |
redis | `{ ok: boolean | null }` |
degraded | StatusDegradedFlag[] | |
checkedAt | string |
STATUS_TICKER_STALE_MS
Constant.
A ticker that has not ticked for this long marks the instance degraded (
ticker_stale).
const STATUS_TICKER_STALE_MS = 5 * 60_000
STATUS_CHECK_INTERVAL_MS
Constant.
const STATUS_CHECK_INTERVAL_MS = 5 * 60_000
STATUS_CHECK_KEEP_DAYS
Constant.
const STATUS_CHECK_KEEP_DAYS = 90
STATUS_BOARD_CACHE_MS
Constant.
How long the StatusBoard block keeps a fetched
/status.jsonbefore asking again.
const STATUS_BOARD_CACHE_MS = 60_000
STATUS_BOARD_TIMEOUT_MS
Constant.
const STATUS_BOARD_TIMEOUT_MS = 3_000
StatusCheckState
Type alias.
type StatusCheckState = 'up' | 'degraded' | 'down'
StatusCheck
Interface.
One row of
status_check: one probe of one instance URL.
| Member | Type | Notes |
|---|---|---|
id | string | |
url | string | |
checkedAt | string | |
state | StatusCheckState | |
latencyMs | `number | null` |
version | `string | null` |
error | `string | null` |
Shared saved views
SavedViewState
Interface.
What a saved view remembers on a list screen: the filters, the sort, the hidden columns, the density and the page size.
| Member | Type | Notes |
|---|---|---|
filters | Record<string, string> | |
sort | `{ key: string | null; dir: 'asc' |
hidden | string[] | |
density | `'compact' | 'default' |
limit? | number |
SharedView
Interface.
A saved view shared on one site. Personal views stay user preferences (
views:<table>, see UserPreference); a shared view is a row of its own that everyone it names sees in the same Views menu, under "Shared views".rolesempty means everyone with a role on the site; otherwise people holding one of those roles on the site (their instance roles plus the roles granted for that site; section roles do not count).isDefault: the view the screen opens with for that audience when the person has no default of their own. Setting it clears the flag on the table's other shared views whose audience overlaps, so each person meets at most one shared default per screen.GET /api/views?table=<key> → SharedView[]: the shared views of that table the caller sees, each with
inAudience(any signed-in user; withviews.share, every shared view of the table on the site, so managers can edit views for roles they do not hold). POST /api/views { table, name, view, roles?, isDefault? } → SharedView (201;views.share) PUT /api/views/:id { name?, view?, roles?, isDefault? } → SharedView (views.share) DELETE /api/views/:id → 204 (views.share)tableis the screen's storage key (1 to 100 letters, digits, dots, colons, dashes or underscores, e.g.content:page);nameis 1 to 60 characters; the view's JSON is at most 16 KB. Mutations are audited as view.share.create, .update and .delete.
| Member | Type | Notes |
|---|---|---|
id | string | |
siteId | string | |
table | string | |
name | string | |
view | SavedViewState | |
roles | Role[] | |
isDefault | boolean | |
createdBy | `string | null` |
createdAt | string | |
updatedAt | string | |
inAudience? | boolean | On GET /api/views only: whether the caller is in the view's audience (a manager also sees views for roles they do not hold). |
Recurrence helpers
Weekday
Type alias.
Date ranges and recurrence: the
daterangefield value and a dependency-free RRULE subset (FREQ, INTERVAL, BYDAY for weekly rules, UNTIL, COUNT, EXDATE) expanded in the event's own time zone through Intl, so a 10:00 weekly meeting stays at 10:00 across a DST change.Pure functions only: the server fills
entity_occurrencewith them and the admin previews the next occurrences with the same code.
type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
RecurrenceFreq
Type alias.
type RecurrenceFreq = 'daily' | 'weekly' | 'monthly' | 'yearly'
Recurrence
Interface.
| Member | Type | Notes |
|---|---|---|
freq | RecurrenceFreq | |
interval? | number | Every n days/weeks/months/years; default 1. |
byWeekday? | Weekday[] | Weekly rules only: the weekdays an occurrence falls on (defaults to the start's weekday). |
until? | string | Last possible start, inclusive. A date-only value means the end of that day in the event's time zone. |
count? | number | Total number of occurrences the rule generates (before exceptions are removed). |
exceptions? | string[] | Skipped starts: instants, or date-only strings that remove whatever falls on that local day. |
DateRangeValue
Interface.
| Member | Type | Notes |
|---|---|---|
start | string | ISO instant, or a local date-time / date in timezone when it carries no offset. |
end? | string | |
allDay? | boolean | |
timezone? | string | IANA zone, e.g. "America/New_York"; defaults to UTC. |
recurrence? | Recurrence |
Occurrence
Interface.
| Member | Type | Notes |
|---|---|---|
start | string | ISO instant (UTC). |
end? | string |
WEEKDAYS
Constant of type Weekday[].
const WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
RECURRENCE_FREQS
Constant of type RecurrenceFreq[].
const RECURRENCE_FREQS = ['daily', 'weekly', 'monthly', 'yearly']
MAX_OCCURRENCES
Constant.
Hard cap on how many occurrences one rule may produce, whatever the window.
const MAX_OCCURRENCES = 2000
isValidTimezone()
Function.
True when Intl knows the zone.
function isValidTimezone(tz: string): boolean
localFields()
Function.
The wall-clock fields of a UTC instant in
tz.
function localFields(utcMs: number, tz: string): LocalFields
localToUtc()
Function.
The UTC instant of a wall-clock time in
tz; a time inside a DST gap resolves to the later side.
function localToUtc(f: LocalFields, tz: string): number
parseInTimezone()
Function.
Parses a
daterangestart/end/until string into a UTC instant, treating offset-less values as local totz.
function parseInTimezone(value: string, tz: string, endOfDay = false): number | null
ExpandOptions
Interface.
| Member | Type | Notes |
|---|---|---|
from? | `string | Date` |
until | `string | Date` |
max? | number | Cap on returned occurrences (default MAX_OCCURRENCES). |
expandOccurrences()
Function.
Expands a date range (recurring or not) into concrete occurrences inside a window, oldest first. A value without a recurrence yields its single occurrence when it falls inside the window.
function expandOccurrences(value: DateRangeValue, opts: ExpandOptions): Occurrence[]
nextOccurrences()
Function.
The next
noccurrences fromfrom(default now), for previews.
function nextOccurrences(value: DateRangeValue, n = 5, from: Date = new Date()): Occurrence[]
describeRecurrence()
Function.
A short human description of a rule, e.g. "Every 2 weeks on Mon, Wed until 2026-12-31".
function describeRecurrence(rec: Recurrence | undefined): string
checkDateRange()
Function.
Validates a daterange value's shape; returns a message or null.
function checkDateRange(value: unknown): string | null
Spillway 0.1.0 · Edit this page on GitHub