Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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

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.

MemberTypeNotes
keystringmachine name, ^[a-z][a-z0-9_]*$
labelstring
typeFieldType
required?boolean
multiple?booleanarray of the type
help?stringshown under the field in the admin
aiHint?stringwhat belongs in this field; read by AI tasks
options?string[]for 'select'
refType?stringfor 'reference': content type key
vocabulary?stringfor 'taxonomy': Vocabulary.key
default?unknown
translatable?booleandefault true for text-like fields
searchable?booleaninclude in site search index (default true for text)

ContentType

Interface.

MemberTypeNotes
keystring^[a-z][a-z0-9_]*$, e.g. "page", "post", "case_study"
labelstring"Case study"
labelPluralstring"Case studies"
description?string
icon?stringoptional icon name for the admin
hasPathbooleanentities get a public URL
pathPattern?stringe.g. "/blog/{slug}", "/{slug}"; tokens: {slug}, {id}, {type}, {yyyy}, {mm}
titleFieldstringwhich field is the display title (default "title")
fieldsFieldDef[]
createdAtstring
updatedAtstring

Entities and revisions

EntityStatus

Type alias.

type EntityStatus = 'draft' | 'review' | 'published' | 'archived'

Entity

Interface.

MemberTypeNotes
idstringuuid v7 / ulid-like, string
typestringContentType.key
siteIdstring
localestringBCP 47, e.g. "en"
statusEntityStatus
titlestringdenormalised display title
slug`stringnull`
path`stringnull`
authorIdstring
dataRecord<string, unknown>user-defined fields, keyed by FieldDef.key
currentRevisionIdstringlatest saved revision (draft or published)
publishedRevisionId`stringnull`
createdAtstring
updatedAtstring
publishedAt`stringnull`
publishAt?`stringnull`
unpublishAt?`stringnull`
lastScheduleError?`stringnull`
sectionId?`stringnull`
workstreamId?`stringnull`
inWorkstream?booleanSet 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?stringLocalization (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.

MemberTypeNotes
idstring
entityIdstring
numbernumber1, 2, 3 ... per entity
statusEntityStatusstatus at the time of the snapshot
titlestring
dataRecord<string, unknown>
authorIdstring
note?string
createdAtstring
workstreamId?`stringnull`

Puck page data (mirror of @puckeditor/core Data, kept dependency-free)

PuckComponentData

Interface.

MemberTypeNotes
typestring
props{ id: string; [key: string]: unknown }
readOnly?Record<string, boolean>

PuckData

Interface.

MemberTypeNotes
contentPuckComponentData[]
root{ props?: Record<string, unknown> }
zones?Record<string, PuckComponentData[]>

Users, roles, permissions

Role

Type alias.

type Role = 'viewer' | 'author' | 'editor' | 'manager' | 'admin'

User

Interface.

MemberTypeNotes
idstring
emailstring
namestring
rolesRole[]
createdAtstring
lastLoginAt`stringnull`
notifyOnReview?booleanPer-user notification preferences (Phase 2b); default true. PUT /api/auth/me.
notifyOnComment?boolean
authProvider?stringHow this user signs in: 'password' or the slug of the SSO provider that created or linked the account.
externalId?`stringnull`
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?`stringnull`
active?booleanFalse 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.

MemberTypeNotes
idstring"default" for v0.1
namestring
tagline?string
baseUrl?stringe.g. https://example.org
defaultLocalestring"en"
localesstring[]
localePrefixDefault?booleanPrefix paths of the default locale too (/en/about instead of /about). Default false; see the localization contract.
homePageId`stringnull`
themeThemeTokens
seo`{ titleSuffix?: string; defaultDescription?: string; ogImageMediaId?: stringnull; robots?: string; llmsTxt?: boolean }`
identity?SiteIdentityBrand → 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?BrandGuideSettingsBrand → 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?stringBrand 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?SiteIntegrationsanalytics tags and custom head markup (Phase 2b)
privacy?SitePrivacyconsent mode for tags and the returning-visitor cookie
forms?SiteFormsSite-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?SiteMediaSettings → Media: the DAM connector (provider, write-only credentials, default folder). See the DAM contract at the end of this file.
cdn?SiteCdnSettings → Cache: the CDN purge hook (provider, write-only credentials, zone id, cache headers). See the CDN contract at the end of this file.
key?stringMultisite identity (same row as the settings): see Site. Present on every SiteSettings the server returns.
domains?string[]
primaryDomain?`stringnull`
status?SiteStatus
createdAtstring
updatedAtstring

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.

MemberTypeNotes
colorPrimarystringCSS color
colorTextstring
colorBackgroundstring
fontBodystringCSS font-family stack
fontHeadingstring
radiusstringe.g. "8px"
maxWidthstringe.g. "1120px"
colorSecondary?stringExtended 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?stringThe body text size, e.g. "16px" or "1rem" (10 to 32 px, 0.625 to 2 rem or em).
typeScaleRatio?stringThe type scale ratio as a number in a string, e.g. "1.25" (1 to 2).
spaceUnit?stringThe base spacing step, e.g. "8px" (2 to 32 px, 0.125 to 2 rem or em).
shadow?stringA CSS box-shadow for raised surfaces, e.g. "0 1px 3px rgba(0,0,0,.12)".
modes?ThemeModesColor 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.

MemberTypeNotes
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 logoMediaId in place of the site name (and logoOnDarkMediaId under prefers-color-scheme: dark when dark mode is on); faviconMediaId becomes <link rel="icon">; logoMarkMediaId (the square mark) is stored for later use. The social image stays under SEO (seo.ogImageMediaId).

MemberTypeNotes
logoMediaId?`stringnull`
logoMarkMediaId?`stringnull`
logoOnDarkMediaId?`stringnull`
faviconMediaId?`stringnull`

SITE_IDENTITY_KEYS

Constant of type readonly (keyof SiteIdentity)[].

const SITE_IDENTITY_KEYS = ['logoMediaId', 'logoMarkMediaId', 'logoOnDarkMediaId', 'faviconMediaId']

MediaItem

Interface.

MemberTypeNotes
idstring
siteId?stringmultisite: the owning site (the library is per site; file URLs are global)
filenamestring
mimestring
sizenumber
width`numbernull`
height`numbernull`
altstring
title?string
urlstringpublic URL, e.g. /media/<id>/<filename>
uploadedBystring
createdAtstring
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?MediaSourceSet 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".

MemberTypeNotes
error{ code: string; message: string; details?: unknown }

Paginated

Interface.

MemberTypeNotes
itemsT[]
totalnumber
pagenumber
limitnumber

SetupStatus

Interface.

GET /api/setup/status. setupTokenRequired: the server has SETUP_TOKEN set, so POST /api/setup needs the X-Setup-Token header.

MemberTypeNotes
installedboolean
versionstring
setupTokenRequired?boolean

SetupRequest

Interface.

POST /api/setup (header X-Setup-Token when the status says setupTokenRequired)

MemberTypeNotes
siteNamestring
emailstring
passwordstring
namestring

LoginRequest

Interface.

POST /api/auth/login

MemberTypeNotes
emailstring
passwordstring

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_found when 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.

MemberTypeNotes
keystring
valueunknown
updatedAtstring

MeResponse

Interface.

GET /api/auth/me → { user: User | null; permissions: Permission[] }

MemberTypeNotes
user`Usernull`
permissionsPermission[]Effective permissions on the request's site (global roles ∪ site roles, plus the ambient subset granted by section roles).
siteId?stringMultisite: 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)

MemberTypeNotes
typestring
titlestring
slug?`stringnull`
locale?string
dataRecord<string, unknown>
sectionId?`stringnull`

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.

MemberTypeNotes
idstring
siteId?stringmultisite: the owning site
fromstringpath, e.g. "/old-page" (exact match, case-insensitive, trailing slash ignored)
tostringpath or absolute URL
status`301302`
enabledboolean
hitsnumber
createdAtstring
updatedAtstring

Collections (saved queries, "Views" for marketers)

CollectionOp

Type alias.

type CollectionOp = 'eq' | 'neq' | 'in' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'exists' | 'has_term'

CollectionFilter

Interface.

MemberTypeNotes
fieldstring
opCollectionOp
value?unknown

Collection

Interface.

MemberTypeNotes
idstring
siteId?stringmultisite: the owning site (keys are unique per site)
keystringCONTENT_TYPE_KEY_RE
labelstring
typestringContentType.key
filtersCollectionFilter[]
sort`{ field: string; dir: 'asc''desc' }`
limitnumber1..100
expand?`'occurrences'null`
createdAtstring
updatedAtstring

CollectionItem

Interface.

MemberTypeNotes
idstringpath is the public path (locale prefix included for non-default locales).
typestring
titlestring
path`stringnull`
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.

MemberTypeNotes
idstring
typestring
titlestring
path`stringnull`
snippetstring
scorenumber
updatedAtstring
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).

MemberTypeNotes
typeRecord<string, number>
termsRecord<string, number>

SearchResponse

Interface (extends Paginated<SearchResult>).

MemberTypeNotes
facetsSearchFacets

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.

MemberTypeNotes
fieldstring
opFormConditionOp
value?`stringnumber

FORM_CONDITION_OPS

Constant of type FormConditionOp[].

const FORM_CONDITION_OPS = ['equals', 'notEquals', 'contains', 'isEmpty', 'isNotEmpty', 'gt', 'lt']

FormField

Interface.

MemberTypeNotes
keystring
labelstring
typeFormFieldType
required?boolean
options?string[]
placeholder?string
help?string
visibleWhen?FormConditionShown 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?numberfile fields: largest accepted file in bytes (default FORM_UPLOAD_DEFAULT_MAX_BYTES, never above FORM_UPLOAD_MAX_BYTES).
maxCount?numberfile 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.

MemberTypeNotes
titlestring
fieldsstring[]

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.

MemberTypeNotes
idstring
siteId?stringmultisite: the owning site (keys are unique per site)
keystringCONTENT_TYPE_KEY_RE
labelstring
fieldsFormField[]
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.
submitLabelstring
successMessagestring
connectorsFormConnector[]
captureUtmbooleanstore utm_* params, referrer and landing page with the submission
createdAtstring
updatedAtstring

FormDelivery

Interface.

MemberTypeNotes
connectorFormConnector['kind']
okboolean
error?string
atstring

FormSubmissionFile

Interface.

A file a visitor attached to a file field; the bytes live in the private form-uploads store, never under /media.

MemberTypeNotes
idstring
fieldstring
filenamestring
mimestring
sizenumber

FormSubmission

Interface.

MemberTypeNotes
idstring
formIdstring
siteId?string
dataRecord<string, unknown>
meta{ ip?: string; userAgent?: string; referrer?: string; page?: string; utm: Record<string, string>; consent?: boolean; captcha?: FormCaptchaProvider }
status`'received''delivered'
deliveriesFormDelivery[]
files?FormSubmissionFile[]Uploaded files (data[field] holds the file name(s)); downloadable through GET /api/forms/:id/submissions/:sid/files/:fid.
createdAtstring

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 (fields helpful = yes|no, comment, hidden page and version) over the last days days (1–365, default 30) and the last five comments. The Dashboard shows it as the "Docs feedback" card when a form with the key docs_feedback exists on the site.

MemberTypeNotes
formIdstring
formKeystring
daysnumber
totalnumber
yesnumber
nonumber
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 ********).

MemberTypeNotes
captcha?{ provider: FormCaptchaProvider; siteKey?: string; secret?: string }

Editorial review

EntityComment

Interface.

MemberTypeNotes
idstring
entityIdstring
fieldKey`stringnull`
authorIdstring
authorNamestring
bodystring
resolvedAt`stringnull`
createdAtstring
revisionId?`stringnull`

ReviewQueueItem

Interface.

MemberTypeNotes
entityEntity
authorNamestring
waitingSincestring
commentCountnumber

EntityCheck

Interface.

MemberTypeNotes
idstring
level`'error''warning'`
messagestring
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.

MemberTypeNotes
modelstring
baseUrl?string
hasKeyboolean

AiSettings

Interface.

MemberTypeNotes
providersPartial<Record<AiProvider, AiProviderSettings>>
tasksRecord<AiTask, { provider: AiProvider; model?: string }>
monthlyTokenCap`numbernull`
enabledboolean

AiSettingsInput

Interface (extends Partial<Omit<AiSettings, 'providers'>>).

MemberTypeNotes
providers?`Partial<Record<AiProvider, { model?: string; baseUrl?: string; apiKey?: stringnull }>>`

BrandProfile

Interface.

MemberTypeNotes
namestring
voicestringhow we sound, 2 to 5 sentences
tonestring[]e.g. ["warm", "direct", "plain-spoken"]
audiencestring
terminologystring[]preferred terms, "use X not Y"
bannedWordsstring[]
boilerplatestringthe one-paragraph description of the organization
examplesstring[]short samples of on-brand copy
updatedAtstring

AiAssistAction

Type alias.

type AiAssistAction = 'write' | 'rewrite' | 'shorten' | 'expand' | 'tone' | 'fix' | 'translate' | 'summarize'

AiAssistRequest

Interface.

MemberTypeNotes
actionAiAssistAction
text?string
instruction?string
locale?stringfor translate
field?{ key: string; label: string; aiHint?: string; type?: FieldType }
context?{ title?: string; typeLabel?: string }

AiPageRequest

Interface.

MemberTypeNotes
briefstring
typeKey?string
goal?string
audience?string

AiPageResponse

Interface.

MemberTypeNotes
titlestring
layoutPuckData
notesstring[]
usageAiUsage

AiUsage

Interface.

MemberTypeNotes
providerAiProvider
modelstring
inputTokensnumber
outputTokensnumber
msnumber

AiUsageReport

Interface.

MemberTypeNotes
monthstring
callsnumber
inputTokensnumber
outputTokensnumber
byTaskRecord<string, { calls: number; tokens: number }>
byUserRecord<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.

MemberTypeNotes
idstring
userIdstring
labelstring
prefixstring
permissionsPermission[]
lastUsedAt`stringnull`
expiresAt`stringnull`
createdAtstring

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.

MemberTypeNotes
labelstring
permissions?Permission[]
expiresInDays?`numbernull`

ApiTokenCreateResponse

Interface.

MemberTypeNotes
tokenApiToken
secretstring

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.

MemberTypeNotes
namestring^[a-z][a-z0-9-]*$ (optionally scoped: @scope/name)
versionstringsemver
tierModuleTier
descriptionstring
spillwaystringsemver range of core this module supports, e.g. "^0.1.0"
capabilitiesCapability[]
hooks?ModuleHook[]
mainstringentry: .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. array items hold the other kinds.

type ModuleBlockFieldType = 'text' | 'textarea' | 'number' | 'select' | 'radio' | 'boolean' | 'media' | 'link' | 'array'

ModuleBlockField

Interface.

MemberTypeNotes
typeModuleBlockFieldType
label?string
placeholder?string
options?`{ label: string; value: stringnumber
min?numbernumber
max?number
step?number
arrayFields?Record<string, ModuleBlockField>array: the fields of one item (every kind but array)
maxItems?numberarray: a cap on the number of items (the host caps every array at 100)

ModuleBlockDef

Interface.

MemberTypeNotes
keystring^[a-z][a-z0-9-]{0,63}$, unique within the module
labelstring
category?stringthe builder's sidebar group label; default "Modules"
description?string
fieldsRecord<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?StyledCoreBlockBlock 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.

MemberTypeNotes
label?string
optionsBlockStyleSlotOption[]
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.

MemberTypeNotes
modulestring
tierModuleTier

BuilderConfig

Interface.

MemberTypeNotes
blocksBuilderModuleBlock[]
safeModeboolean
stylesBuilderBlockStyle[]
styleSlotsRecord<string, BlockStyleSlot[]>

BlockRenderResult

Interface.

POST /api/render/block ({ module, key, props, path?, locale? }) → BlockRenderResult: the HTML the ModuleBlock would render on a page.

MemberTypeNotes
htmlstring
placeholderboolean
labelstring
reason?string

InstalledModule

Interface.

MemberTypeNotes
idstring
namestring
versionstring
tierModuleTier
enabledboolean
manifestModuleManifest
source`'local''registry'
installedAtstring
lastError`stringnull`
settingsRecord<string, unknown>

ModulesStatus

Interface.

GET /api/modules/status

MemberTypeNotes
safeModeboolean
restartPendingboolean
restartReason`stringnull`
bootIdstring
coreVersionstring
modulesDirstring

RegistryEntry

Interface.

GET /api/modules/registry?q= → { items: RegistryEntry[] }

MemberTypeNotes
namestring
descriptionstring
lateststring
versionsstring[]
tierModuleTier
homepage?string
license?string

Phase 2b editorial contract

EntitySchedule

Interface.

Scheduling

MemberTypeNotes
publishAt`stringnull`
unpublishAt`stringnull`

ScheduledItem

Interface.

Entity gains publishAt, unpublishAt and lastScheduleError; 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; scheduled is 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-scheduled from 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.

MemberTypeNotes
entityEntity
authorNamestring
publishAt`stringnull`
unpublishAt`stringnull`
lastScheduleError`stringnull`

SCHEDULE_TICK_MS

Constant.

const SCHEDULE_TICK_MS = 30_000

EntityLock

Interface.

Content locking

MemberTypeNotes
entityIdstring
userIdstring
userNamestring
acquiredAtstring
heartbeatAtstring
expiresAtstring

LOCK_TTL_MS

Constant.

POST /api/entities/:id/lock (acquire or heartbeat; 409 lock_held with 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=1 with 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.

MemberTypeNotes
op`'eq''ins'
textstring

FieldDiff

Interface.

MemberTypeNotes
keystring
labelstring
kindDiffKind
beforeunknown
afterunknown
inline?InlineDiffPart[]

LayoutBlockDiff

Interface.

MemberTypeNotes
idstring
typestring
change`'added''removed'

RevisionDiff

Interface.

MemberTypeNotes
a{ id: string; number: number; createdAt: string }
b{ id: string; number: number; createdAt: string }
fieldsFieldDiff[]
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-revisions CLI 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 carries error and the run's location in note.

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.

MemberTypeNotes
idstring
eventNotificationEvent
channelNotificationChannel
enabledboolean
filter?{ types?: string[] }
createdAtstring
updatedAtstring

NotificationRuleInput

Type alias.

type NotificationRuleInput = Omit<NotificationRule, 'id' | 'createdAt' | 'updatedAt'>

OutboxStatus

Type alias.

type OutboxStatus = 'pending' | 'delivered' | 'failed'

OutboxItem

Interface.

MemberTypeNotes
idstring
eventNotificationEvent
ruleId`stringnull`
channelNotificationChannel['kind']
targetstringwho or where: the recipient list, the webhook URL or "slack"
subjectstringone line describing the event, e.g. "Review requested: About us"
statusOutboxStatus
attemptsnumber
lastError`stringnull`
nextAttemptAt`stringnull`
createdAtstring
deliveredAt`stringnull`

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 outbox table 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, notifyOnComment

booleans 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.

MemberTypeNotes
idstring
keystringCONTENT_TYPE_KEY_RE, e.g. "topics", "departments"
labelstring
hierarchicalbooleanwhen false the admin keeps every term at the root
description?string
termPathPattern?stringe.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 "/").
createdAtstring
updatedAtstring

Term

Interface.

MemberTypeNotes
idstring
vocabularyIdstring
parentId`stringnull`
labelstring
slugstringunique among siblings
description?string
weightnumbersort order among siblings
path`stringnull`
depthnumber0 for root terms
dataRecord<string, unknown>
createdAtstring
updatedAtstring

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.

MemberTypeNotes
idstring
parentId`stringnull`
weightnumber

TermNode

Interface (extends Term).

Nested shape returned by GET /api/vocabularies/:id/terms (ordered by weight, then label).

MemberTypeNotes
childrenTermNode[]

Image derivatives

MediaFit

Type alias.

type MediaFit = 'cover' | 'contain' | 'inside'

MediaFormat

Type alias.

type MediaFormat = 'webp' | 'avif' | 'jpeg' | 'png'

MediaUrlOptions

Interface.

MemberTypeNotes
w?number
h?number
fit?MediaFit
fmt?MediaFormat
q?number

MEDIA_SRCSET_WIDTHS

Constant.

Width breakpoints emitted in srcset by 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.

srcset text for the given widths (defaults to MEDIA_SRCSET_WIDTHS, skipping widths wider than the source when maxWidth is 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.

richtext values 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.

MemberTypeNotes
idstring
keystringCONTENT_TYPE_KEY_RE
labelstring
blockPuckComponentData
createdAtstring
updatedAtstring

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.

MemberTypeNotes
fieldstring
labelstring
kindExposedFilterKind

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: search matches title and summary-like fields (contains), select and taxonomy match one value (taxonomy values are term ids; options come from the vocabulary when it exists, otherwise from the distinct stored values), daterange applies from/to to occurrence.start for 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. The Calendar block (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.

MemberTypeNotes
idstring
siteId?string
levelSiteAlertLevel
titlestring
bodystring
href?string
startsAt`stringnull`
endsAt`stringnull`
dismissibleboolean
enabledboolean
createdAtstring
updatedAtstring

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>=1 for 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.

MemberTypeNotes
gtmId?stringGTM-XXXXXXX: the shell injects the container snippet in <head> and the noscript iframe after <body>
ga4Id?stringG-XXXXXXXXXX: gtag.js; skipped when gtmId is set (GTM should load GA)
plausibleDomain?string
customHeadHtml?stringRaw markup for <head>, limited to <script>, <meta> and <link> tags (site.settings only; other tags are stripped).

Kits (config as code)

KitVocabulary

Interface.

MemberTypeNotes
keystring
labelstring
hierarchical?boolean
description?string
termPathPattern?string
terms?KitTerm[]

KitTerm

Interface.

MemberTypeNotes
labelstring
slug?string
description?string
children?KitTerm[]

KitPage

Interface.

MemberTypeNotes
titlestring
slugstring
layoutPuckData
status`'published''draft'`
seoDescription?string
home?boolean

KitMenu

Interface.

MemberTypeNotes
keystring
labelstring
items{ label: string; href: string; children?: { label: string; href: string }[] }[]

KitEntity

Type alias.

Sample content: EntityInput plus status and optional ref so 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) or base64 (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).

MemberTypeNotes
keystring
filenamestring
url?string
base64?string
altstring
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.

MemberTypeNotes
modules?{ name: string; version?: string }[]
kits?{ key: string; version?: string }[]
core?string

KitManifest

Interface.

MemberTypeNotes
keystringCONTENT_TYPE_KEY_RE
labelstring
descriptionstring
versionstring
kind?`'kit''theme'
brand?KitBrand
requires?KitRequirements
typesContentTypeInput[]
vocabularies?KitVocabulary[]
collections?`Omit<Collection, 'id''createdAt'
forms?`Omit<FormDefinition, 'id''createdAt'
menus?KitMenu[]
media?KitMedia[]
pages?KitPage[]
sampleContent?KitEntity[]

KitReportItem

Interface.

MemberTypeNotes
kind`'type''vocabulary'
keystring
action`'created''updated'
reason?string

KitInstallReport

Interface.

brandImport: a brand kit's rows waiting for review (Brand → Import brand); nothing else is installed.

MemberTypeNotes
kitstring
versionstring
itemsKitReportItem[]
creatednumber
updatednumber
skippednumber
brandImport?BrandImportResult

KitSummary

Interface.

MemberTypeNotes
keystring
labelstring
descriptionstring
versionstring
kind?`'kit''theme'
installedboolean
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. action says what the install would do; a plan whose ok is false names the problem in problems and the install is refused with the same message.

MemberTypeNotes
keystring
labelstring
versionstring
source`'local''registry'`
name?string
action`'install''skip'`
reason?string
requiredBy?string

KitPlanModule

Interface.

MemberTypeNotes
namestring
version?string
action`'install''skip'
tier?ModuleTier
installedVersion?string
reason?string
requiredBystring

KitInstallPlan

Interface.

MemberTypeNotes
kitstring
versionstring
okboolean
problemsstring[]
kitsKitPlanKit[]
modulesKitPlanModule[]
core`{ version: string; range: stringnull; ok: boolean }`

Bulk actions and clone

BulkAction

Type alias.

type BulkAction = 'publish' | 'unpublish' | 'archive' | 'delete' | 'submit' | 'setAuthor' | 'addTerm'

BulkRequest

Interface.

MemberTypeNotes
idsstring[]
actionBulkAction
value?unknown

BulkResultItem

Interface.

MemberTypeNotes
idstring
okboolean
status?EntityStatus
error?string

BulkResponse

Interface.

MemberTypeNotes
resultsBulkResultItem[]
oknumber
failednumber

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. value is 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.

MemberTypeNotes
idstring
slugstring
labelstring
protocolSsoProtocol
enabledboolean
issuer?string
clientId?string
hasClientSecret?boolean
scopes?string[]oidc: discovery at <issuer>/.well-known/openid-configuration
entryPoint?string
idpCert?string
spEntityId?stringsaml: 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.
jitProvisioningboolean
defaultRolesRole[]
groupClaim?string
groupRoleMapRecord<string, Role[]>
allowedDomains?string[]
createdAtstring
updatedAtstring

SsoProviderInput

Interface (extends Partial<Omit<SsoProvider, 'id' | 'createdAt' | 'updatedAt' | 'hasClientSecret'>>).

MemberTypeNotes
clientSecret?`stringnull`

SsoPublicProvider

Interface.

MemberTypeNotes
slugstring
labelstring
protocolSsoProtocol

SsoTestResult

Interface.

POST /api/sso/providers/:id/test → what the IdP answered (OIDC: discovery and JWKS; SAML: the certificate and the SP metadata URL).

MemberTypeNotes
okboolean
protocolSsoProtocol
messagestring
detailsRecord<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 a sso-secret key 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.

MemberTypeNotes
idstring
siteId?string
keystring
labelstring
rulesAudienceRule[]
match`'all''any'`
createdAtstring
updatedAtstring

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.

MemberTypeNotes
keystring
labelstring
weightnumber

Experiment

Interface.

MemberTypeNotes
idstring
siteId?string
keystring
labelstring
statusExperimentStatus
variantsExperimentVariant[]
goalExperimentGoal
startedAt`stringnull`
endedAt`stringnull`
createdAtstring
updatedAtstring

ExperimentInput

Type alias.

type ExperimentInput = Pick<Experiment, 'key' | 'label' | 'variants' | 'goal'>

ExperimentResult

Interface.

MemberTypeNotes
experimentIdstring
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, b and the optional c.

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; slots default and variant), Experiment (experimentKey; slots a, b, optional c).

Islands: cached pages render these blocks as <div data-sw-island="<entityId>/<blockId>"> holding the default/a content 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 path goals, 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.

MemberTypeNotes
daystring
viewsnumber
visitorsnumber

AnalyticsOverview

Interface.

MemberTypeNotes
fromstring
tostring
viewsnumber
visitorsnumber
byDayAnalyticsDay[]
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.

MemberTypeNotes
pathstring
fromstring
tostring
viewsnumber
visitorsnumber
byDayAnalyticsDay[]

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).

MemberTypeNotes
consentModeConsentMode

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.

MemberTypeNotes
formatTokenImportFormat
contentstring

TokenImportResult

Interface.

MemberTypeNotes
tokensPartial<ThemeTokens> & { custom?: Record<string, string> }
mapped{ source: string; target: string; value: string }[]
unmapped{ source: string; value: string }[]
warningsstring[]

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 dark group, or modes.dark/themes.dark, and $extensions.mode.dark on 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 (targets modes.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. updatedBy is a user id (null for a draft written without a user). styles is 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, with builtIn, modified and exactly one isDefault per block.

MemberTypeNotes
themeThemeTokens
identitySiteIdentity
stylesBlockStyle[]
guideBrandGuideSettings
profileBrandProfileBrand → Voice, drafted with the rest (the published profile until the draft changes it).
locksBrandPart[]The parts this site locks for the sites that inherit its brand (always empty on a site that inherits one).
overridesBrandPart[]On a site that inherits a brand: the parts this draft overrides (every other part shows the parent's published values).
updatedAtstring
updatedBy`stringnull`

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. profile merges field by field; locks replaces the locked parts.

MemberTypeNotes
theme?Partial<ThemeTokens>
identity?SiteIdentity
styles?BlockStyleInput[]
guide?BrandGuideSettings
profile?Partial<Omit<BrandProfile, 'updatedAt'>>
locks?BrandPart[]

BrandRevision

Interface.

One published version. styles holds the site's own styles as published (SiteSettings.blockStyles); profile is null for versions published before 0041.

MemberTypeNotes
idstring
themeThemeTokens
identitySiteIdentity
stylesBlockStyle[]
guideBrandGuideSettings
profile`BrandProfilenull`
locksBrandPart[]
publishedBy`stringnull`
publishedAtstring

BrandPublishResult

Interface.

MemberTypeNotes
revisionBrandRevision
siteSiteSettings

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 parent

site'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.

MemberTypeNotes
idstring
keystring
namestring

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: its children, and locked as published (the draft's are in BrandDraft.locks). A site with neither answers empty lists and a null parent.

MemberTypeNotes
parent`BrandSiteRefnull`
childrenBrandSiteRef[]
lockedBrandPart[]
inheritedBrandPart[]
overriddenBrandPart[]

KitBrand

Interface.

The brand a kind "brand" kit carries. Identity values are $media:<key> references to the kit's media (the logo files).

MemberTypeNotes
themePartial<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, styles and guide come 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. id names what it writes: theme.<token> (a named token), theme.modes.dark.enabled and theme.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) or profile.<field> (the voice). current is the draft's value (or the published one) when the import was read; null when unset.

MemberTypeNotes
idstring
groupBrandImportGroup
labelstring
sourcestring
valuestring
current`stringnull`

BrandImportLeftOut

Interface.

Something the source carried that the import did not map, and why.

MemberTypeNotes
sourcestring
valuestring
reasonstring

BrandImportLogo

Interface.

A logo the import found (website) or was given (PDF source): filed in the media library only when its row is applied.

MemberTypeNotes
filenamestring
mime`stringnull`
bytes`numbernull`
src`stringnull`

BrandImportProfile

Type alias.

The voice fields an import can draft (the brand profile without its timestamp).

type BrandImportProfile = Partial<Omit<BrandProfile, 'updatedAt'>>

BrandImportResult

Interface.

MemberTypeNotes
idstring
sourceBrandImportSource
labelstringWhat was read: the token format, the URL, or the PDF's file name.
themePartial<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?BrandGuideSettingsA brand kit's style guide setting.
mappedBrandImportRow[]
unmappedBrandImportLeftOut[]
warningsstring[]
ai`'used''off'
createdAtstring
expiresAtstringUnapplied 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.

MemberTypeNotes
rowsstring[]

BrandImportApplyResult

Interface.

profile echoes 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. draft is the draft after the apply (null only when nothing was applied and the site has no draft).

MemberTypeNotes
draft`BrandDraftnull`
profile`BrandImportProfilenull`
appliedstring[]
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 block

then follows the site's Card styles), or styleSlots declares 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 as props.style

and the slot values as props.styleValues; the wrapper carries the style's class.

Per role: a style with roles is offered in the page builder only to people with one of those site roles (an empty list is

everyone); 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), and sw-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; value matches BLOCK_STYLE_KEY_RE.

MemberTypeNotes
valuestring
labelstring

BlockStyleSlot

Interface.

What a styled block lets vary: one slot, its allowed values and the value a new style starts with.

MemberTypeNotes
keystring
labelstring
help?string
optionsBlockStyleSlotOption[]
defaultstring

BlockStyle

Interface.

A named block style. block is the style set: a core block type ("Hero") or a module block's own set ("<module>/<blockKey>"). tokens maps every slot of the block to one of its values (a slot left out takes its default). roles limits who may pick it in the page builder (empty or absent: everyone). builtIn and modified are 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.

MemberTypeNotes
blockstring
keystring
labelstring
description?string
tokensRecord<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: available is false when the person's role may not pick it.

MemberTypeNotes
availableboolean

BlockStylesInfo

Interface.

GET /api/brand/styles → the published styles and the slots of every styled block (core blocks and loaded module blocks).

MemberTypeNotes
stylesBlockStyle[]
slotsRecord<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

(blockStyleSampleProps in @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. public puts the published guide at BRAND_GUIDE_PATH.

MemberTypeNotes
publicboolean

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).

MemberTypeNotes
keykeyof SiteIdentity
labelstring
mediaIdstring
urlstring
filenamestring
mimestring

BrandGuide

Interface.

GET /api/brand/guide → BrandGuide. html is 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. styles is the full style list the guide shows; public is this brand's style guide setting.

MemberTypeNotes
brand`'published''draft'`
htmlstring
sections{ id: BrandGuideSectionId; label: string }[]
stylesBlockStyle[]
logosBrandGuideLogo[]
publicboolean
updatedAt`stringnull`

Navigation menus

MenuItem

Interface.

MemberTypeNotes
idstring
labelstring
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.

MemberTypeNotes
idstring
keystring
labelstring
itemsMenuItem[]
createdAtstring
updatedAtstringMultisite: 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 main and footer are 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 shows main (two levels, <details>-based submenus and mobile toggle, no JS), footer shows footer; 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.

MemberTypeNotes
idstring
siteIdstring
keystringCONTENT_TYPE_KEY_RE-like: ^[a-z][a-z0-9_-]*$, unique per site
labelstring
description?string
statusWorkstreamStatus
ownerIdstring
publishAt`stringnull`
createdAtstring
updatedAtstring
mergedAt`stringnull`
itemCountnumber
lastMergeError?`stringnull`

WorkstreamItemKind

Type alias.

type WorkstreamItemKind = 'entity' | 'menu' | 'settings'

WorkstreamItemAction

Type alias.

type WorkstreamItemAction = 'update' | 'create' | 'delete'

WorkstreamItem

Interface.

MemberTypeNotes
workstreamIdstring
kindWorkstreamItemKind
targetIdstringentity id, menu id, or "default" for the site settings
revisionId?stringentities: the workstream revision that holds the branch version
actionWorkstreamItemAction
updatedAtstring
authorIdstring
baseVersion?`stringnull`
title?string

WorkstreamDiffItem

Interface.

MemberTypeNotes
kindWorkstreamItemKind
targetIdstring
titlestring
actionWorkstreamItemAction
fieldChangesnumber
layoutChangesnumber
checksEntityCheck[]
revisionId?stringEntities: the workstream revision and the main-line revision it is compared with (GET /api/entities/:id/revisions/:a/diff/:b for the details).
baseRevisionId?`stringnull`
path?`stringnull`

WorkstreamConflict

Interface.

MemberTypeNotes
targetIdstring
kindWorkstreamItemKind
reason`'live_changed''deleted'`

WorkstreamDiff

Interface.

MemberTypeNotes
itemsWorkstreamDiffItem[]
conflictsWorkstreamConflict[]

WorkstreamInput

Interface.

MemberTypeNotes
key?string
labelstring
description?string

WorkstreamShareLink

Interface.

MemberTypeNotes
tokenstring
urlstring
expiresAtstring

WorkstreamMergeResult

Interface.

MemberTypeNotes
workstreamWorkstream
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 422 checks_failed (details: { targetId, title, checks }[]) when any item has an error-level check, unless force. 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 409 workstream_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 with inWorkstream; 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/:id and /_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 event workstream.merged carries 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 with isSource: 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.localePrefixDefault boolean to prefix the default too. Paths are resolved per (site, locale, path). hreflang alternates 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.uiLocale preference.

type TranslationStatus = 'current' | 'outdated' | 'missing'

TranslationMode

Type alias.

type TranslationMode = 'empty' | 'copy' | 'ai'

TranslationsResponse

Interface.

GET /api/entities/:id/translations. group holds every translation (source first), missing the site locales with no translation yet.

MemberTypeNotes
groupEntity[]
missingstring[]

TranslationCreateRequest

Interface.

POST /api/entities/:id/translations. empty copies shared fields and the title only; copy copies everything; ai translates text fields and layout copy (ai.use).

MemberTypeNotes
localestring
modeTranslationMode

TranslationCreateResponse

Interface (extends Entity).

The created draft, plus which fields the model wrote (ai mode) and the usage it cost.

MemberTypeNotes
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).

MemberTypeNotes
defaultLocalestring
localesRecord<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). messages is the requested bundle over the en one, so every key resolves; locale is the bundle actually served.

MemberTypeNotes
localestring
availablestring[]
messagesRecord<string, string>

isTranslatableField()

Function.

Field-level sharing between translations. translatable: true fields hold locale-specific values; translatable: false fields 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; en is 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) for

previews and the admin switcher when the user can open that site, else the default site.

type SiteStatus = 'active' | 'paused'

Site

Interface.

MemberTypeNotes
idstring
keystringSITE_KEY_RE, unique
namestring
domainsstring[]host names (no scheme, no port), unique across sites; aliases of primaryDomain
primaryDomain`stringnull`
defaultLocalestring
localesstring[]
statusSiteStatus'paused': the public site answers 503, the API still works
brandParentId`stringnull`
createdAtstring
updatedAtstring

SiteInput

Interface.

brandParent (id or key, null to stop inheriting) is set by sites.manage only; see "Shared brands".

MemberTypeNotes
keystring
namestring
domains?string[]
primaryDomain?`stringnull`
defaultLocale?string
locales?string[]
status?SiteStatus
brandParent?`stringnull`

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 main and footer menus 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 site table row (GET/PUT /api/site

act 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 no

global 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).

MemberTypeNotes
siteIdstring
rolesRole[]

UserSectionRole

Interface.

MemberTypeNotes
sectionIdstring
rolesRole[]

MeSitesResponse

Interface.

MemberTypeNotes
sites(Site & { roles: Role[] })[]
currentSiteId`stringnull`

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.

MemberTypeNotes
idstring
siteIdstring
keystringCONTENT_TYPE_KEY_RE-like, unique per site
labelstring
pathPrefixstringe.g. "/engineering"; content whose path equals it or starts with "<prefix>/" belongs to the section
menuKey?stringa menu of the site the section's editors may edit
createdAtstring
updatedAtstring

SectionInput

Interface.

MemberTypeNotes
keystring
labelstring
pathPrefixstring
menuKey?`stringnull`

MeSection

Interface.

What GET /api/auth/me reports per section the user has a role in on the request's site.

MemberTypeNotes
idstring
keystring
labelstring
pathPrefixstring
menuKey?string
rolesRole[]
permissionsPermission[]

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: null in a PUT asks

for 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 path falls 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.

slug segments 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.

MemberTypeNotes
idstring
userIdstring
handlestring
displayNamestring
url?string
avatarUrl?string
verifiedboolean
createdAtstring

Namespace

Interface.

MemberTypeNotes
namestring
ownerPublisherIdstring
maintainersstring[]
createdAtstringSet 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?`stringnull`

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.

MemberTypeNotes
idstring
handlestring
displayNamestring
url?string
avatarUrl?string
verifiedboolean
createdAtstring

RegistryOrgMember

Interface.

MemberTypeNotes
publisherIdstring
handlestring
displayNamestring
roleOrgRole
sincestring

RegistryOrgMembership

Interface (extends RegistryOrg).

GET /api/registry/orgs/mine: an organization with the caller's role in it.

MemberTypeNotes
roleOrgRole

RegistryOrgDetail

Interface.

GET /api/registry/orgs/:id (members and reviewers) and GET /registry/v1/orgs/:handle (public, packages instead of namespaces).

MemberTypeNotes
orgRegistryOrg
membersRegistryOrgMember[]
namespacesNamespace[]

RegistryPackage

Interface.

MemberTypeNotes
namestring
kindPackageKind
tier?ModuleTier
descriptionstring
namespace`stringnull`
publisherIdstring
latest`stringnull`
downloadsnumber
reviewedboolean
deprecatedboolean
deprecationNote?string
homepage?string
repository?string
license?string
keywordsstring[]
createdAtstring
updatedAtstring

PackageVersion

Interface.

MemberTypeNotes
namestring
versionstring
sha256string
sizenumber
manifest`ModuleManifestKitManifest
readmestring
changelog?string
spillwayRangestring
capabilities?Capability[]
checks`{ id: string; status: 'pass''warn'
status`'listed''pending_review'
yankReason?string
publishedAtstring
downloadsnumber
filesstring[]every entry of the tarball, recorded at publish time

Advisory

Interface.

MemberTypeNotes
idstring
packagestring
versionsstring
severity`'low''moderate'
titlestring
url?string
createdAtstring

RegistryStats

Interface.

GET /registry/v1/stats

MemberTypeNotes
packagesnumber
versionsnumber
publishersnumber
downloadsnumber
byKindRecord<PackageKind, number>
pendingReviewnumber

AgentPackageManifest

Interface.

GET /registry/v1/packages/:name/manifest.json: what an agent needs to install and configure a package from a conversation.

MemberTypeNotes
namestring
kindPackageKind
tier?ModuleTier
latest`stringnull`
descriptionstring
install{ command: string; api?: string }
capabilitiesCapability[]
config`{ settings: Record<string, unknown>null }`
docs`{ readme: string; homepage: stringnull; repository: string
advisoriesAdvisory[]

REGISTRY_PACKAGE_NAME_RE

Constant.

Package kinds: a module ships manifest.json (ModuleManifest, tier decides the review path); a kit ships kit.json (KitManifest, plus optional

name, homepage, repository, license, keywords, spillway); a theme is a kit manifest with "kind": "theme" and screenshots: string[]

(paths of PNG/JPEG/WebP files inside the tarball, at least one); a brand is a kit manifest with "kind": "brand" (KitBrand, logos as

media), checked by a dry run that reads it into a brand import on a scratch site and confirms nothing went live, and listed under

brands in the index. The package name is the manifest name (kits: the key with _ → - when unset).

Publishing: POST /registry/v1/publish (multipart: tarball; bearer token with the registry.publish permission, a new Permission granted to every

authenticated 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. fourEyes is REGISTRY_FOUR_EYES=1: approve

answers 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 ownerOrgId instead of ownerPublisherId. 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 to publisher; 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: modules holds listed module

versions only, with sha256, tier, manifest and an absolute tarball URL; kits, themes and brands hold the other kinds in the same shape with kind;

signed with REGISTRY_SIGNING_KEY (private key PEM path) when set; generatedAt and registry (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=unreviewed lists 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 theme in 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 ThemeGallery

and 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 is

POST /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: true and 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|sign already exist; spillway registry serve is REGISTRY_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 (issuer may name a GitHub Enterprise origin); JIT provisioning creates a user

with the viewer role plus registry.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.manage and are scoped to the

request'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;

status is ok|redirect|error, cluster a cluster id or none, q matches 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: sandbox so 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 import AI task when configured and the job's options.useAi is not false, and strictly validated; the one-off pages form a cluster mapped to page

and 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 (merged needs 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 } } (redirectsDropped counts source redirects to other hosts left out under options.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;

publish applies 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: one import task run per classification that has at least one cluster besides the one-off pages, sending every such cluster in

one 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 the

sitemap (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 own useAi): clusters the crawled pages the way classify will

and 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 is

in 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.

MemberTypeNotes
maxPagesnumberdefault 200, cap 5000
rateLimitMsnumberdefault 500
respectRobotsbooleandefault true
includestring[]glob (/news/**) or re: regex on the path; empty means everything
excludestring[]
followSubdomainsbooleandefault false
useSitemapbooleandefault true
adapters`('drupal''wordpress')[]`
locale?string
workstreambooleandefault true: land inside a workstream named after the job
offsiteRedirects?`'keep''drop'`
useAi?booleanUse 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.

MemberTypeNotes
discoverednumber
fetchednumber
failednumber
skippednumber
bytesnumber
assetsnumber
clustersnumber
convertednumber
landednumber
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.

MemberTypeNotes
platform`'drupal''wordpress'
version?string
generator?string
apisstring[]
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.

MemberTypeNotes
idstring
siteIdstring
sourceUrlstring
statusImportJobStatus
optionsImportJobOptions
statsImportStats
detectedImportDetected
workstreamId`stringnull`
error`stringnull`
createdBystring
createdAtstring
updatedAtstring

ImportPageMeta

Interface.

MemberTypeNotes
description?string
canonical?string
lang?string
ogRecord<string, string>
jsonLdunknown[]
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.

MemberTypeNotes
idstring
jobIdstring
urlstring
pathstring
finalUrlstring
statusCodenumber
contentTypestring
fetchedAtstring
depthnumber
titlestring
metaImportPageMeta
headings{ level: number; text: string }[]
mainHtmlstring
textstring
wordCountnumber
skeletonHashstring
urlPatternstring
links{ href: string; text: string; internal: boolean }[]
images{ src: string; alt: string; width?: number; height?: number }[]
videosstring[]
forms{ action: string; method: string; fields: { name: string; type: string; label: string; required: boolean; options?: string[] }[] }[]
breadcrumbsstring[]
clusterId`stringnull`
typeKey`stringnull`
fields`Record<string, unknown>null`
blocks`unknownnull`
entityId`stringnull`
quality`ImportQualitynull`
error`stringnull`

ImportPageSummary

Type alias.

type ImportPageSummary = Omit<ImportPage, 'mainHtml' | 'text' | 'blocks'>

ImportQuality

Interface.

MemberTypeNotes
scorenumber
issues`{ code: string; severity: 'info''warn'
textRetainednumber
imagesRetainednumber
linksRetainednumber

TypeProposal

Interface.

MemberTypeNotes
keystring
namestring
descriptionstring
reasonstring
fields`{ key: string; label: string; type: string /* existing field type names /; required: boolean; source: string / how it is filled: 'title''meta:description'
pathPatternstring
sampleUrlsstring[]
existingTypeKey?stringwhen it matches a type already on the site

ImportCluster

Interface.

MemberTypeNotes
idstring
jobIdstring
labelstring
skeletonHashstring
urlPatternstring
pageCountnumber
sampleUrlsstring[]
proposal`TypeProposalnull`
decision`'pending''approved'
mergedIntoId`stringnull`
targetTypeKey`stringnull`

ImportAsset

Interface.

MemberTypeNotes
idstring
jobIdstring
sourceUrlstring
kind`'image''document'
bytesnumber
mediaId`stringnull`
status`'pending''downloaded'
error`stringnull`
contentType?`stringnull`

ImportRedirect

Interface.

MemberTypeNotes
idstring
jobIdstring
sourcePathstring
targetPathstring
statusnumbercrawl when the crawler met it, source when the platform's redirect table listed it.
origin`'crawl''source'`

ImportMenu

Interface.

MemberTypeNotes
idstring
jobIdstring
keystring
labelstring
itemsImportMenuItem[]

ImportMenuItem

Interface.

MemberTypeNotes
labelstring
hrefstring
children?ImportMenuItem[]

ImportReport

Interface.

MemberTypeNotes
jobImportJob
clustersImportCluster[]
pages{ total: number; byStatus: Record<string, number>; byType: Record<string, number> }
quality{ average: number; below70: number; issuesByCode: Record<string, number> }
redirectsnumber
menusnumber
assets{ downloaded: number; failed: number }
theme{ extracted: boolean; tokens?: number }
durationMsnumber

ImportThemeExtract

Interface.

MemberTypeNotes
tokensThemeTokens
customRecord<string, string>
fonts`{ family: string; source: 'google''system'
logo`{ src: string; alt: string }null`
favicon`stringnull`
palette`{ color: string; count: number; role?: 'primary''accent'
sourcesstring[]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.

MemberTypeNotes
stage`'before_crawl''after_crawl'`
ai`{ enabled: boolean; configured: boolean; provider: AiProvidernull; model: string
useAibooleanThe per-job switch (options.useAi).
willUseAibooleanTrue when classification will call the model: useAi, AI enabled and configured, and at least one cluster to refine possible.
pages`{ cap: number; sitemap: numbernull; 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: numbernull; usedThisMonth: number; remaining: number
exceedsCapbooleanTrue when the AI will be used and tokens.max exceeds the remaining cap: the wizard asks for an explicit confirmation.
summarystringOne sentence for people: "About 1 AI call (2 at most), roughly 4,000 to 31,000 tokens."
notesstring[]

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 rateLimitMs says (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.

MemberTypeNotes
confidenceRecord<string, number>Token key (the seven named tokens and the custom names) → 0..1.
spacingstring[]The spacing scale found in margin, padding and gap values (px, ascending); also emitted as custom tokens space-xs … space-4xl.
radiistring[]Border radius values seen, most frequent first.
warningsstring[]
extractedAtstring
appliedboolean
appliedAt`stringnull`
logoMediaId`stringnull`

ImportThemeApplyRequest

Interface.

MemberTypeNotes
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.

MemberTypeNotes
original{ html: string; text: string; images: string[]; links: string[] }
converted{ blocks: unknown; html: string; text: string }
quality`ImportQualitynull`

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 from

user 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" (only eq; 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: true restores 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.

MemberTypeNotes
idstring
providerIdstring
prefixstring
createdBy`stringnull`
createdAtstring
lastUsedAt`stringnull`

ScimTokenCreateResponse

Interface.

MemberTypeNotes
tokenScimToken
secretstring

ScimEvent

Interface.

MemberTypeNotes
idstring
actionstring
actorId`stringnull`
targetTypestring
targetIdstring
createdAtstring
detailunknown

ScimStatus

Interface.

MemberTypeNotes
baseUrlstring
token`ScimTokennull`
lastSyncAt`stringnull`
eventsScimEvent[]

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 an

on-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.unpublished or

entity.deleted posts WebhookPayload (JSON) with X-Spillway-Event and X-Spillway-Signature: sha256=<HMAC-SHA256 of the body>

through the outbox (retries, SSRF guard); data.entity.cacheTags names what to purge and data.entity.publicPath the 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.

MemberTypeNotes
idstring
key?string
namestring
tagline?string
originstringThe Spillway origin pages, media and forms live at (BASE_URL, else the site's baseUrl, else the request origin).
baseUrl?string
defaultLocalestring
localesstring[]
localePrefixDefault?boolean
homePageId`stringnull`
themeThemeTokens
themeCssstringThe theme tokens as a :root{...} rule, what the server inlines ahead of the block CSS.
seo{ titleSuffix?: string; defaultDescription?: string; ogImageUrl?: string }

PublicMenuItem

Interface.

MemberTypeNotes
labelstring
hrefstring
newTab?boolean
current?boolean
children?PublicMenuItem[]

PublicMenu

Interface.

MemberTypeNotes
keystring
labelstring
itemsPublicMenuItem[]

PublicSeo

Interface.

MemberTypeNotes
titlestring
description?string
canonical?string
ogImage?string
noindexboolean
alternates{ hreflang: string; href: string }[]
jsonLdRecord<string, unknown>[]

PublicRender

Interface.

MemberTypeNotes
entityEntity
type`Pick<ContentType, 'key''label'
pathstringThe public path (locale prefix included) and locale of what was rendered.
localestring
data`PuckDatanull`
richHtmlRecord<string, string>Rich text fields rendered to sanitised HTML (internal links resolved, images responsive).
mediaRecord<string, MediaItem>Media items referenced by media fields, by id, with absolute URLs.
seoPublicSeo
menus{ main?: PublicMenuItem[]; footer?: PublicMenuItem[] }
cacheTagsstring[]
sitePublicSite
previewboolean
queryDependent?booleanTrue when a block read the query string (exposed filters, calendar): cache per query, briefly, like the server does.

PublicPath

Interface.

MemberTypeNotes
idstring
typestring
titlestring
pathstring
localestring
updatedAtstring
publishedAt`stringnull`

PublicPathsResponse

Interface.

MemberTypeNotes
itemsPublicPath[]

WebhookEntity

Interface.

What a webhook notification rule posts (see the Notifications contract above for the headers).

MemberTypeNotes
idstring
typestring
titlestring
path`stringnull`
publicPath`stringnull`
localestring
statusstring
authorIdstring
adminUrlstring
cacheTagsstring[]

WebhookPayload

Interface.

MemberTypeNotes
eventNotificationEvent
atstring
subjectstring
site`{ name: string; baseUrl: stringnull }`
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) or block:<blockId>:<prop path> (block:Cards-1:cards.0.title; nested slot blocks

get 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 copy

when 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 502 tms_error with the provider's message, the manual

provider 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)> or X-Webhook-Secret: <webhookSecret>,

marks the jobs the payload names (or every in-progress job of the provider) ready and queues a translation.ready notification.

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) or userId + secret (Smartling user identifier and secret).

MemberTypeNotes
token?string
userId?string
secret?string

TmsSettings

Interface.

GET /api/i18n/tms/settings. Secrets come back as '**' when set and are absent otherwise; configured is true when the provider can be used.

MemberTypeNotes
providerTmsProvider
projectId`stringnull`
baseUrl`stringnull`
credentialsTmsCredentials
webhookSecret`stringnull`
configuredboolean
webhookUrl`stringnull`
updatedAt`stringnull`

TmsSettingsInput

Interface.

PUT /api/i18n/tms/settings. Secrets: a string sets, the mask keeps, null clears.

MemberTypeNotes
provider?TmsProvider
projectId?`stringnull`
baseUrl?`stringnull`
credentials?`{ token?: stringnull; userId?: string
webhookSecret?`stringnull`

TmsTestResult

Interface.

MemberTypeNotes
okboolean
messagestring
providerTmsProvider

TmsStatus

Interface.

GET /api/i18n/tms/status (content.view_drafts): what editors need before sending, without the settings themselves.

MemberTypeNotes
providerTmsProvider
namestring
configuredboolean
manualboolean

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.

MemberTypeNotes
idstring
typestring
titlestring
localestring
unitsnumber

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.

MemberTypeNotes
appliedstring[]
skipped{ id: string; reason: string }[]
unmatchedstring[]

TranslationJobFileReport

Interface (extends XliffImportReport).

MemberTypeNotes
entityIdstring
localestring
targetEntityId`stringnull`
appliedAtstring

TranslationJobReport

Interface.

MemberTypeNotes
filesTranslationJobFileReport[]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?stringThe last message from the provider or the last error.

TranslationJob

Interface.

MemberTypeNotes
idstring
siteIdstring
providerTmsProvider
externalId`stringnull`
externalUrl`stringnull`
entitiesTranslationJobEntity[]
sourceLocalestring
targetLocalesstring[]
statusTranslationJobStatus
reportTranslationJobReport
error`stringnull`
due`stringnull`
createdBystring
createdAtstring
updatedAtstring

TranslationJobInput

Interface.

POST /api/i18n/tms/jobs. Entities are sent as their translation group's source; due is an ISO date handed to the provider.

MemberTypeNotes
entityIdsstring[]
targetLocalesstring[]
due?`stringnull`

Digital asset management (DAM) connectors

DamProvider

Type alias.

A media item can reference an asset in a DAM instead of a local file: MediaItem.source names the provider, the asset's id in it,

the asset's public delivery URL, a version and the provider's metadata. Its url stays 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() and MediaItem.variants give the DAM's own delivery URLs directly, with the provider's transformation syntax for

width, 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 secret

ones 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 (source gone,

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

errors and 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.

MemberTypeNotes
providerDamProvider
externalIdstringThe asset's id in the DAM (Cloudinary public_id, Bynder media id, Brandfolder asset id).
urlstringThe asset's public delivery URL as the DAM gave it (the original; transformations are derived from it).
version?stringBumped 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.

MemberTypeNotes
provider`DamProvider'none'`
credentials?Record<string, string>Per provider, see DAM_CREDENTIAL_FIELDS; secret fields are write-only.
defaultFolder?string

SiteMedia

Interface.

MemberTypeNotes
dam?SiteDam

DamCredentialField

Interface.

MemberTypeNotes
keystring
labelstring
secretboolean
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 secret ones.

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).

MemberTypeNotes
externalIdstring
namestring
kindDamAssetKind
mime`stringnull`
size`numbernull`
width`numbernull`
height`numbernull`
urlstringThe public delivery URL of the original.
thumbnailUrl`stringnull`
altstringAlt text from the DAM's metadata ('' when it has none).
title`stringnull`
version`stringnull`
updatedAt`stringnull`
metadataRecord<string, unknown>

DamSearchResponse

Interface.

MemberTypeNotes
itemsDamAsset[]
pagenumber
total`numbernull`
nextCursor`stringnull`
providerDamProvider
namestring

DamImportRequest

Interface.

MemberTypeNotes
externalIdstring
alt?string
title?string

DamTestResponse

Interface.

MemberTypeNotes
okboolean
provider`DamProvider'none'`
namestring
messagestring

DamWebhookError

Interface.

MemberTypeNotes
siteIdstring
messagestring

DamWebhookResponse

Interface.

MemberTypeNotes
changedstring[]
errorsDamWebhookError[]

DamDeliveryOptions

Interface (extends MediaUrlOptions).

MemberTypeNotes
focal?{ x: number; y: number }
width?`numbernull`
height?`numbernull`

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 also

reaches 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_purge table. 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 tagPurge is set, otherwise by URL in batches of 30 with

backoff 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 everything beyond it.

Settings (PUT /api/site { cdn }, site.settings): the secret credentials are write-only (encrypted with the instance secret, echoed

back as "**"); zoneId is the Cloudflare zone, Fastly service or Bunny pull zone id; purgeEverythingOnTheme turns a theme

or settings save into a purge of everything; sMaxAge and staleWhileRevalidate set 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.

MemberTypeNotes
provider`CdnProvider'none'`
credentials?Record<string, string>Per provider, see CDN_CREDENTIAL_FIELDS; secret fields are write-only.
zoneId?stringCloudflare zone id, Fastly service id or Bunny pull zone id.
tagPurge?booleanCloudflare: the zone is on an Enterprise plan, so purges go by Cache-Tag instead of by URL.
purgeEverythingOnTheme?booleanA theme or site settings save purges everything on the CDN (assets and untagged responses included) instead of the tags.
sMaxAge?numberCache-Control s-maxage of public pages in seconds (default CDN_DEFAULT_S_MAXAGE).
staleWhileRevalidate?numberCache-Control stale-while-revalidate of public pages in seconds (default CDN_DEFAULT_STALE_WHILE_REVALIDATE).

CdnCredentialField

Interface.

MemberTypeNotes
keystring
labelstring
secretboolean
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 secret ones.

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.

MemberTypeNotes
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).

MemberTypeNotes
idstring
siteIdstring
providerCdnProvider
triggerCdnPurgeTrigger
actorId`stringnull`
tagsstring[]
urlsstring[]
everythingboolean
statusCdnPurgeStatus
attemptsnumber
lastError`stringnull`
note`stringnull`
nextAttemptAt`stringnull`
createdAtstring
deliveredAt`stringnull`

CdnTestResponse

Interface.

MemberTypeNotes
okboolean
provider`CdnProvider'none'`
namestring
messagestring

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.

MemberTypeNotes
seenAtstring
completedboolean

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).

MemberTypeNotes
keyOnboardingStepKey
doneboolean
optionalboolean
detail?`Record<string, stringnumber

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:tour preference). complete is true when every non-optional step is done; dismissed is the caller's own choice (onboarding:checklist preference); runtime says 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.

MemberTypeNotes
stepsOnboardingStep[]
donenumberNon-optional steps done, and how many there are.
totalnumber
completeboolean
dismissedboolean
runtime`'docker''source'`
versionstring
kitsstring[]The installed kits by key, for the checklist's first step.
homePageId`stringnull`

Backups (docs/install.md "Backups")

BackupStatus

Type alias.

Instance configuration, not site settings: BACKUP_TARGET (s3://bucket/prefix through the S3-compatible client in

core/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, or spillway.pgdump from pg_dump on

Postgres), backup.json (version, applied migrations, checksums, encryption, warnings), keys.json (the instance's key

files 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.json and, 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 by

content hash under form-uploads/, modules/ and registry/), 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 live

in a DAM and are not copied); media files are stored once by content hash under media/<sha256> and only new hashes are

uploaded 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_secrets warning.

The ticker runs the schedule (jobs/backup.ts), records every run in backup_run (migration 0034), prunes runs older than

the retention and emits backup.failed to 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. location is the run's folder on the target (s3://bucket/prefix/<stamp> or a directory).

MemberTypeNotes
idstring
triggerBackupTrigger
statusBackupStatus
startedAtstring
finishedAt`stringnull`
bytes`numbernull`
location`stringnull`
error`stringnull`
mediaUploaded`numbernull`
mediaTotal`numbernull`
version`stringnull`
database`'sqlite''postgres'
warningsBackupWarning[]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.

MemberTypeNotes
configuredboolean
target`stringnull`
schedule`stringnull`
retentionDaysnumber
encryptedboolean
nextRunAt`stringnull`
lastRun`BackupRunnull`

BackupsResponse

Interface.

MemberTypeNotes
settingsBackupSettingsInfo
runsBackupRun[]

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) and GET /status.json (InstanceStatus) with Cache-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.json stays the instance's.

The StatusBoard block (packages/blocks) lists other instances: the server fetches each one's /status.json through the

SSRF-guarded fetch with a 3 s timeout and keeps the answer for 60 s; the 90-day bar comes from status_check, filled by

the 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.

MemberTypeNotes
status`'up''degraded'`
versionstring
uptimeSecondsnumber
startedAtstring
database`'sqlite''postgres'`
cache`'memory''redis'`
search`'sqlite''postgres'
ticker`{ enabled: boolean; lastTickAt: stringnull; stale: boolean }`
backup`{ configured: boolean; lastRunAt: stringnull; lastStatus: BackupStatus
redis`{ ok: booleannull }`
degradedStatusDegradedFlag[]
checkedAtstring

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.json before 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.

MemberTypeNotes
idstring
urlstring
checkedAtstring
stateStatusCheckState
latencyMs`numbernull`
version`stringnull`
error`stringnull`

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.

MemberTypeNotes
filtersRecord<string, string>
sort`{ key: stringnull; dir: 'asc'
hiddenstring[]
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". roles empty 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; with views.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) table is the screen's storage key (1 to 100 letters, digits, dots, colons, dashes or underscores, e.g. content:page); name is 1 to 60 characters; the view's JSON is at most 16 KB. Mutations are audited as view.share.create, .update and .delete.

MemberTypeNotes
idstring
siteIdstring
tablestring
namestring
viewSavedViewState
rolesRole[]
isDefaultboolean
createdBy`stringnull`
createdAtstring
updatedAtstring
inAudience?booleanOn 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 daterange field 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_occurrence with 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.

MemberTypeNotes
freqRecurrenceFreq
interval?numberEvery 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?stringLast possible start, inclusive. A date-only value means the end of that day in the event's time zone.
count?numberTotal 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.

MemberTypeNotes
startstringISO instant, or a local date-time / date in timezone when it carries no offset.
end?string
allDay?boolean
timezone?stringIANA zone, e.g. "America/New_York"; defaults to UTC.
recurrence?Recurrence

Occurrence

Interface.

MemberTypeNotes
startstringISO 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 daterange start/end/until string into a UTC instant, treating offset-less values as local to tz.

function parseInTimezone(value: string, tz: string, endOfDay = false): number | null

ExpandOptions

Interface.

MemberTypeNotes
from?`stringDate`
until`stringDate`
max?numberCap 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 n occurrences from from (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

Was this page helpful?