The package registry
Spillway can run as the registry that other Spillway instances download modules, themes and kits from. It is the same server with one switch: REGISTRY_HOST=1. spillwaycms.com runs one; so can a company that keeps its own modules private. Without the switch nothing is mounted and GET /registry/v1/* answers 404.
What a registry does:
- takes publishes from
spillway publish(or an upload from a signed-in session), validates them, runs automated checks and stores the tarballs; - serves the public JSON API under
/registry/v1(search, package pages, downloads, publisher profiles, advisories, stats) with CORS and a 60 s cache; - generates the signed index that the module system in every Spillway instance consumes (
REGISTRY_URL); the sha256 of every tarball is in it, and the whole document is signed with the registry's ed25519 key; - holds trusted modules for human review, and lets reviewers deprecate, yank and attach security advisories.
Running a registry
spillway registry keygen --out ./keys # private.pem and public.txt
REGISTRY_HOST=1 REGISTRY_SIGNING_KEY=./keys/private.pem BASE_URL=https://registry.example.org spillway start
# or: spillway registry serve (sets REGISTRY_HOST=1 and starts the server)
| Variable | Default | What it does |
|---|---|---|
REGISTRY_HOST | unset | 1 mounts the registry routes (/registry/v1/*, /api/registry/*). |
REGISTRY_SIGNING_KEY | unset | Path of the ed25519 private key PEM from spillway registry keygen. When set, index.json carries a signature; sites that set REGISTRY_PUBLIC_KEY to the matching public.txt refuse anything else. Unset serves an unsigned index (fine for a private registry on a trusted network). |
REGISTRY_STORAGE | <DATA_DIR>/registry | Where tarballs are kept: <storage>/<name>/<version>.tgz (acme__name for @acme/name). Back it up with the database; a version whose file is missing answers 500 tarball_missing on download. |
BASE_URL must be set: the tarball URLs in the index are absolute (<BASE_URL>/registry/v1/packages/<name>/<version>/download) and a consuming site refuses http:// tarballs unless it runs with REGISTRY_ALLOW_HTTP=1.
Point a Spillway instance at it with REGISTRY_URL=https://registry.example.org/registry/v1/index.json and REGISTRY_PUBLIC_KEY=<contents of public.txt>; System → Modules → Install from registry then searches and installs from it, verifying the signature and every sha256 (see modules.md).
What can be published
| Kind | Manifest at the root | Listed |
|---|---|---|
| Sandboxed module | manifest.json with "tier": "sandboxed" and a .wasm entry | at once, after the automated checks |
| Trusted module | manifest.json with "tier": "trusted" and a .js entry | after a reviewer approves it (reachable by direct link, with a warning, meanwhile) |
| Kit | kit.json (a kit manifest) | at once, after an installer dry run |
| Theme | kit.json with "kind": "theme" and "screenshots": ["screenshot.png"] | at once, after the dry run and a screenshot check |
| Brand | kit.json with "kind": "brand" (a brand kit) | at once, after a brand dry run |
A theme is a kit: tokens, blocks and page templates with screenshots. The theme directory is the kit registry filtered by kind, so a theme manifest is a kit manifest (pages, menus, media, optional types) plus kind: "theme" and at least one screenshot (PNG, JPEG, WebP or GIF, listed in screenshots and shipped in the tarball). A kit without kind is a kit. Every listed theme gets a preview site rendered from its tarball.
A brand is a kit too: a site's brand as Brand → Export writes it (kits.md, Brand kits). The registry lists brands under their own kind (?kind=brand, the Brands tab of the public browse page) and in the brands group of the signed index.
Kit, theme and brand manifests may add the fields the registry shows on the package page: name (the package name; defaults to the kit key with _ turned into -), homepage, repository, license, keywords (up to 20) and spillway (a semver range of supported core versions, default *). Modules take repository and keywords next to the usual manifest fields. README.md and CHANGELOG.md at the root become the package page.
Package names are [a-z0-9-]+ or @namespace/name. The first publish of @acme/anything claims acme for the publisher; from then on only the namespace owner and its maintainers may publish into it (409 namespace_taken for everyone else), and only a package's publisher or a namespace maintainer may publish new versions of it (403 not_owner). Namespaces are first come; disputes are settled by a reviewer with POST /api/registry/namespaces/:name/transfer. A namespace can also belong to an organization.
Organizations
An organization is a handle shared by several publishers that owns namespaces, so a company or a project can publish @acme/* as a team instead of through one person's account. Package pages then say "by Acme" and link /registry/orgs/acme.
- Create one from the admin's Publisher screen (Organizations → Create organization) or with
POST /api/registry/orgs { handle, displayName, url? }. Handles share one space with publisher handles:acmecannot be both a publisher and an organization (409 handle_takeneither way). The creator's publisher becomes the first owner; a user without a publisher row gets one on the spot. - Members and roles. Owners add members by publisher handle (
POST /api/registry/orgs/:id/members { handle, role? }), change roles and remove members. Three roles: owner (everything below plus members, roles and namespaces), admin (publishes under the organization's namespaces, deprecates and yanks their packages, manages maintainers) and member (listed on the profile; publishes only when named as a maintainer). An organization keeps at least one owner: the last owner cannot be demoted, removed or leave (409 last_owner). - Namespaces. The owner publisher of a namespace moves it into an organization they own (
POST /api/registry/orgs/:id/namespaces/:name/transfer; a reviewer may move any namespace, withownerOrgIdon the ordinary transfer route too). From then on the organization's owners and admins, plus the namespace'smaintainers(set withPUT /api/registry/orgs/:id/namespaces/:name/maintainers), publish under it; the publisher who moved it keeps nothing from the move but their role. A plain member who publishes under the namespace gets403 org_role; outsiders keep409 namespace_taken. The namespace rule wins over package ownership: a new version of@acme/xneeds rights on@acme, whoever published the first one. - Leaving. Any member may leave (
POST /api/registry/orgs/:id/leave). Nothing is taken back: the packages and versions a departing member published stay listed and stay on their profile; they just cannot publish new versions under the organization's namespaces any more. - Verified organizations. Reviewers verify an organization (
POST /api/registry/orgs/:id/verify { verified }, the Organizations screen) after an identity check out of band; the verified badge then shows on every package under its namespaces and on its page. - Four-eyes review treats every owner and admin of the organization as a publisher of its packages: with
REGISTRY_FOUR_EYES=1none of them may approve a version published under the organization's namespaces. spillway publishis unchanged: the token's publisher must be an owner, an admin or a maintainer of the namespace.
Every change writes an audit row (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). The public page is GET /registry/v1/orgs/:handle ({ org, members, packages }) and a /registry/orgs/any page holding the OrgProfile block (the public pages).
Publishing
- Sign in to the registry site (GitHub or email), open the account menu → API tokens and create a token. Every account has the
registry.publishpermission. - In the module, kit or theme folder:
spillway publish . --registry https://registry.example.org --token spw_...
# or: SPILLWAY_REGISTRY=https://registry.example.org SPILLWAY_REGISTRY_TOKEN=spw_... spillway publish
The command packs the folder with Spillway's own tar writer (node_modules, .git, tests, lockfiles, .env*, *.tgz and *.map are left out; symlinks are skipped), POSTs it to /registry/v1/publish and prints one line per check:
Packed 3 files (2 KB) from /work/profanity-guard
ok manifest module profanity-guard@0.1.0 (sandboxed), Spillway ^0.1.0
ok secrets No credentials found in the archive
ok capabilities Sandboxed module with hooks
ok smoke main.wasm loads in the sandbox and answers entity.beforeSave
ok size 2 KB, 3 files
Published profanity-guard@0.1.0 (sha256 9f86d0…)
Any other client works too: POST /registry/v1/publish with Authorization: Bearer spw_... and the tarball as multipart field tarball (or a raw application/gzip body), 20 MB at most. 201 returns { package, version, publisher, created }; 202 means the version waits for review; 422 checks_failed lists every check in error.details.checks; 409 version_exists and 409 version_not_greater guard the version rules. A signed-in browser session may use POST /api/registry/publish instead (same body, CSRF header required).
Rules the registry enforces on every publish:
- the manifest validates (the same schema the module system and the kit installer use; a module's
spillwayrange is accepted for any core version); - the version is semver, greater than every existing version of the package, and immutable: a version can never be replaced, only yanked. A rejected version keeps its number; bump and publish again;
- the kind and (for modules) the tier of an existing package cannot change;
- no credentials in the files: private key blocks, AWS, GitHub, Slack, Stripe and Spillway tokens fail the publish, as do
.env,.npmrc,.netrc, key and certificate files; apassword = "…"-shaped assignment is a warning; - the tarball is at most 20 MB, holds no links, absolute paths or
..segments (the reader refuses them) and the entry file the manifest names exists.
Automated checks
| Check | Applies to | What it does |
|---|---|---|
manifest | all | Schema validation; records kind, version, tier and the supported Spillway range. |
secrets | all | The credential scan above. fail refuses the publish. |
capabilities | modules | Lists HTTP hosts and entity.write; a trusted module is a warn that routes it to review. |
smoke | sandboxed modules | Compiles and instantiates the Wasm in the real sandbox host (memory limits, imports against the declared capabilities) and calls the first declared hook once with an empty payload; host calls answer with errors so nothing reaches the network or a database. A module that does not instantiate, or hangs past the limits, fails; a call that crashes on the synthetic payload is a warning. |
screenshots | themes | At least one screenshot, present in the tarball and a real image. |
dependencies | kits and themes (a brand kit has none) | Validates the manifest's requires block (kits.md) and resolves it against this registry's index: a required kit or module that is not listed, or has no listed version in the range, is a warning (the installing site may bundle the kit); a trusted-tier module is noted, because installing sites need SPILLWAY_TRUSTED_MODULE_INSTALL=1 for it; a core range this registry's version does not satisfy is a warning; a kit that requires itself fails. A malformed requires block is a 400 like any other manifest error. |
install | kits and themes | Installs the kit with sample content on a throwaway in-memory site (remote media URLs are not fetched; dependencies are not installed there, the dependencies check covers them) and reports the installer's counts; an installer error fails the publish, items skipped with a reason are a warning. |
install (brand) | brands | Reads the kit into a brand import on a throwaway in-memory site, the same path as installing it (remote logo URLs are not fetched), and checks that it maps to brand values and that nothing went live: the scratch site's theme, identity and draft are unchanged. A kit that maps to nothing, or that changed the brand, fails; values left out are a warning. |
size | all | A warning above 5 MB. |
Check results are stored with the version and shown on the package page (PackageVersion.checks), so the badge a site visitor sees is backed by the actual run.
Review policy
- Sandboxed modules, kits and themes are listed as soon as the checks pass. They carry the automated-checks badge, not the reviewed badge.
- Trusted modules run as server code in whoever installs them. Each version waits in
GET /api/registry/review(users.manage on the registry instance) until a reviewer reads it.POST /api/registry/review/:name/:version/approvelists it and marks the packagereviewed;rejectwith a note keeps it out for good. - Four-eyes review (
REGISTRY_FOUR_EYES=1, off by default):approveanswers403 four_eyeswhen the reviewer is the person who published the version, the package's publisher, an owner or maintainer of its namespace, or an owner or admin of the organization that owns the namespace, so a trusted module is always read by someone else before it is listed;rejectstays open to every reviewer.GET /api/registry/statusreportsfourEyes, and the admin's review queue shows a note while it is on. Recommended on any registry with more than one reviewer (the second security review's G-17). Until approval the package is reachable by direct link (GET /registry/v1/packages/:name) with the version markedpending_review, but it is absent from search, listings and the index (?include=unreviewedshows it on purpose). - What reviewers look for: the capabilities match what the code does; no network calls to undeclared hosts; no reading of environment variables or the data directory beyond the module API; no obfuscated or minified-only code without a repository link; a license. Say why in the note; the publisher sees it.
- Deprecate (
POST /api/registry/packages/:name/deprecate { note }) keeps the package installable but flags it, in the index too (deprecated,deprecationNote). Yank (POST /api/registry/packages/:name/versions/:v/yank { reason }) removes one version from the index and answers410 version_yankedon download;latestmoves to the previous listed version. Publishers may deprecate and yank their own packages (and their namespace's); reviewers may do it to any. - Advisories (
POST /api/registry/advisories { package, versions, severity, title, url? }, reviewers) attach to a semver range and appear in the package response, inGET /registry/v1/advisories?package=and in the agent manifest. Pair a critical advisory with a yank of the affected versions. - Verified publishers:
POST /api/registry/publishers/:id/verify { verified: true }sets the badge after an identity check out of band (an organization's domain, a known maintainer).
Every one of these writes an audit row (registry.publish, registry.approve, registry.reject, registry.deprecate, registry.undeprecate, registry.yank, registry.advisory_create, registry.advisory_delete, registry.namespace_transfer, registry.publisher_verify, registry.publisher_update; the organization actions are listed under Organizations).
Signing
The index is signed the same way spillway registry sign signs a static one: an ed25519 signature over the canonical JSON of the document (keys sorted, no whitespace, the signature field left out). generatedAt, the registry base URL and every package and version are inside the signed bytes, so a CDN or DNS compromise cannot swap tarballs and checksums together, and a replayed old index says when it was made: a consuming site with REGISTRY_PUBLIC_KEY refuses an index older than REGISTRY_MAX_INDEX_AGE_DAYS (30 by default; 0 turns the check off) as registry_stale, and one older than the last index that process accepted as registry_rollback. A hand-signed static index without generatedAt is not aged. The private key stays on the registry host (REGISTRY_SIGNING_KEY, mode 0600); rotate it by generating a new pair, switching the variable and publishing the new public.txt for sites to update REGISTRY_PUBLIC_KEY.
Downloads carry X-SHA256 and an immutable cache header; a consuming Spillway follows at most three redirects by hand (each vetted again, never to file: or a plain-http downgrade), verifies the sha256 from the index before extracting anything, checks that the manifest inside matches the name, version and tier the index promised, and extracts with a reader that refuses links, path escapes and duplicate entries (so the manifest the checks read is the manifest that is installed).
The API
Public, no authentication, Access-Control-Allow-Origin: *, Cache-Control: public, max-age=60 (downloads: immutable):
| Method | Path | Notes |
|---|---|---|
| GET | /registry/v1/index.json | The signed index. modules is exactly what REGISTRY_URL expects ({ description, tier, homepage, license, deprecated?, versions: { [v]: { tarball, sha256, manifest } } }); kits and themes use the same shape with kind. Listed versions only. |
| GET | /registry/v1/packages?q=&kind=module|theme|kit&tier=&sort=downloads|updated|name&page=&limit= | Paginated<RegistryPackage>. q matches name, description and keywords. Unreviewed packages are left out unless include=unreviewed. |
| GET | /registry/v1/packages/:name | { package, versions: PackageVersion[], advisories, publisher, org }. Versions are listed, pending or yanked, newest first; rejected ones are not shown. org is the organization that owns the package's namespace, or null. |
| GET | /registry/v1/packages/:name/:version/download | The tarball (application/gzip, X-SHA256). Counts one download per IP, version and day. 410 for a yanked version. 120 downloads a minute per address. |
| GET | /registry/v1/packages/:name/:version/readme | text/markdown. |
| GET | /registry/v1/packages/:name/:version/files/<path> | One file out of the stored tarball: a screenshot the manifest declares, or README.md, CHANGELOG.md, manifest.json, kit.json. Listed and pending versions; typed by extension (image/png, text/markdown, …), Cache-Control: immutable, not counted as a download. Anything else, including code, is 404. The theme gallery and the package page load screenshots from here. |
| GET | /registry/<name>/preview, /registry/<name>/preview/<page>, ?version= | A listed theme's preview site: HTML, X-Robots-Tag: noindex, Cache-Control: public, max-age=600, X-Spillway-Theme-Version. 404 for anything that is not a listed theme version or one of its pages. |
| GET | /registry/v1/packages/:name/manifest.json | AgentPackageManifest: { name, kind, tier, latest, install: { command, api }, capabilities, config: { settings }, docs: { readme, homepage, repository }, advisories }, for agents that install and configure a package from a conversation. config.settings is the manifest's settingsSchema when it declares one. |
| GET | /registry/v1/advisories?package= | Advisory[]. |
| GET | /registry/v1/publishers/:handle | { publisher, packages }. |
| GET | /registry/v1/orgs/:handle | { org, members, packages }: an organization, its members by handle and role, and the packages of every namespace it owns. |
| GET | /registry/v1/stats | { packages, versions, publishers, downloads, byKind, pendingReview }. |
| POST | /registry/v1/publish | Bearer token with registry.publish; multipart tarball. 30 publishes an hour per user. |
Scoped names work encoded (/registry/v1/packages/%40acme%2Fguard) or as two segments (/registry/v1/packages/@acme/guard/1.0.0/download).
Authenticated, under /api/registry (session with the CSRF header, or a bearer token):
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/registry/status | any user | { enabled: boolean }. The one /api/registry route that exists on every instance: enabled: false when REGISTRY_HOST is unset, so the admin can tell whether its Registry screens apply without probing a 404. |
| POST | /api/registry/publish | registry.publish | The publish pipeline for a signed-in session (the upload form). |
| GET | /api/registry/publishers/me | any user | The caller's Publisher or null. |
| POST | /api/registry/publishers/me | registry.publish | { handle, displayName, url?, avatarUrl? } creates or updates the profile; a Publisher row is otherwise created on the first publish with a handle derived from the email. |
| GET | /api/registry/namespaces/mine | any user | Namespaces the caller owns or maintains. |
| GET | /api/registry/publishers?q=&page=&limit= | users.manage | Paginated<Publisher>, newest first; q matches the handle and display name. The Publishers screen. |
| GET | /api/registry/namespaces?page=&limit= | users.manage | Paginated<Namespace>, alphabetical. The Namespaces screen. |
| GET | /api/registry/review | users.manage | Versions waiting for review, oldest first. |
| POST | /api/registry/review/:name/:version/approve | reject | users.manage | { note? }. With REGISTRY_FOUR_EYES=1, approve answers 403 four_eyes for the version's publisher and the maintainers of its namespace. |
| POST | /api/registry/advisories | users.manage | { package, versions, severity, title, url? } → 201. DELETE /api/registry/advisories/:id. |
| POST | /api/registry/packages/:name/deprecate | undeprecate | publisher, maintainer or users.manage | { note? }. |
| POST | /api/registry/packages/:name/versions/:v/yank | publisher, maintainer or users.manage | { reason }. |
| POST | /api/registry/namespaces/:name/transfer | users.manage | { ownerPublisherId, maintainers? }, or { ownerOrgId } to move the namespace into an organization. |
| POST | /api/registry/publishers/:id/verify | users.manage | { verified }. |
| POST | /api/registry/orgs | registry.publish | { handle, displayName, url?, avatarUrl? } → 201 RegistryOrg; the caller's publisher becomes the owner. 409 handle_taken when a publisher or organization holds the handle. |
| GET | /api/registry/orgs/mine | any user | RegistryOrgMembership[]: the caller's organizations with their role. |
| GET | /api/registry/orgs/:id | member or users.manage | RegistryOrgDetail ({ org, members, namespaces }). |
| PUT | /api/registry/orgs/:id | owner or admin | { handle?, displayName?, url?, avatarUrl? }. |
| POST | /api/registry/orgs/:id/members | owner | { handle, role? } → 201 RegistryOrgMember; 409 for an existing member. |
| PUT | /api/registry/orgs/:id/members/:publisherId | owner | { role: owner | admin | member }; 409 last_owner when it would demote the last owner. |
| DELETE | /api/registry/orgs/:id/members/:publisherId | owner | 204; 409 last_owner. |
| POST | /api/registry/orgs/:id/leave | any member | 204; 409 last_owner. |
| POST | /api/registry/orgs/:id/namespaces/:name/transfer | the namespace's owner publisher who owns the organization, or users.manage | → Namespace with ownerOrgId. |
| PUT | /api/registry/orgs/:id/namespaces/:name/maintainers | owner or admin | { maintainers: publisherId[] }. |
| GET | /api/registry/orgs?q=&page=&limit= | users.manage | Paginated<RegistryOrg>, newest first. The Organizations screen. |
| POST | /api/registry/orgs/:id/verify | users.manage | { verified }. |
Types (Publisher, Namespace, RegistryOrg, RegistryOrgMember, RegistryPackage, PackageVersion, Advisory, RegistryStats, AgentPackageManifest) are in packages/shared/src/index.ts. PackageVersion.files lists every entry of the tarball, recorded at publish time, which is what the review drawer's Files tab shows.
Installing from a registry
A module is installed with POST /api/modules/install { "source": "registry", "name" } (see modules.md). A kit or theme is installed with:
spillway kit install faq-kit --registry https://registry.example.org # latest listed version
spillway kit install @acme/site@2.0.0 --registry https://registry.example.org --sample
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/kits/install-from-registry -d '{"name":"faq-kit","withSampleContent":true}'
A kit that declares requires (kits.md) pulls its dependencies from the same registry: required kits by package name at the highest listed version in range (bundled kits and kits already installed on the site win when they satisfy the range), required modules through the module installer (sandboxed at once; trusted only with SPILLWAY_TRUSTED_MODULE_INSTALL=1 and an admin). POST /api/kits/plan-from-registry { name, version? } (users.manage) returns the resolved plan without installing; an install whose plan is not ok answers 400 kit_dependency with the problems.
POST /api/kits/install-from-registry { name, version?, withSampleContent? } (users.manage) reads the site's REGISTRY_URL index (signature verified when REGISTRY_PUBLIC_KEY is set), downloads the version's tarball, checks its sha256 against the index, opens it with the registry's own reader (no links, no path escapes, the manifest validated with the kit schema), checks that the manifest inside is the package the index promised, and runs the kit installer with the same semantics as POST /api/kits/:key/install → KitInstallReport. 404 not_found names a kit or version the index lacks, 400 sha256_mismatch refuses tampered bytes, and a module name is a 400 (modules go through the module installer). The CLI takes the registry's base URL or its index.json; REGISTRY_PUBLIC_KEY and REGISTRY_ALLOW_HTTP apply as for modules. Every install writes a kit.install_registry audit row. GET /registry/v1/packages/:name/manifest.json carries exactly this command and call in install for kits and themes.
Brand kits
A brand installs the same way (spillway kit install <name> --registry <url>, POST /api/kits/install-from-registry { "name" }) but never touches the live brand: the report's brandImport holds its rows for review in Brand → Import brand, and only the rows a person applies reach the brand draft. The CLI has no review step and applies every row to the draft. See kits.md, Brand kits.
Every fetch the registry client makes goes through the outbound SSRF guard like other outbound requests (security.md): a registry on a private network needs SPILLWAY_ALLOW_PRIVATE_URLS=1 on the consuming site.
Sign-in with GitHub
The registry site lets people sign in with GitHub so publishing needs no separate account. It is an SSO provider with protocol: "github": OAuth 2 authorization code with the read:user user:email scopes, the GitHub user id as the stable identity and the primary verified address from /user/emails as the email (a profile email alone is never trusted; an account without a verified address cannot sign in, no_email).
- On GitHub, Settings → Developer settings → OAuth Apps → New OAuth App. Homepage: your
BASE_URL. Authorization callback URL:<BASE_URL>/sso/github/callback. - In Spillway, Site settings → Sign-in → Add provider, or over the API:
curl -b c -H 'X-Requested-With: spillway' -H 'content-type: application/json' -X POST $BASE/api/sso/providers \
-d '{"slug":"github","label":"GitHub","protocol":"github","enabled":true,"clientId":"Iv1...","clientSecret":"...","jitProvisioning":true,"defaultRoles":["viewer"]}'
issuer may name a GitHub Enterprise origin (https://github.example.com; its API is reached under /api/v3); unset means github.com. scopes and authParams are honored like for OIDC; allowedDomains limits sign-in to verified addresses in those domains; groupClaim does not apply (GitHub sends no groups).
Just-in-time provisioning creates the user with the provider's default roles, viewer unless you change it, which carries registry.publish; the user then creates an API token from the account menu and publishes. The account is linked by GitHub id, so a changed primary email signs into the same account. POST /api/sso/providers/:id/test reports the endpoints and the callback URL to paste into GitHub.
The public pages
The registry's site is ordinary pages holding the registry blocks (blocks.md), so any registry instance can build them in the admin or ship them in a kit. On spillwaycms.com the website repository's kit (website.md) ships them: /registry (RegistryStats and RegistryBrowse), the catch-all /registry/any (RegistryPackage: a published page whose last segment is any answers every path under its parent, so /registry/<name> and /registry/@scope/<name> land on it), /registry/publishers/any (PublisherProfile), /registry/orgs/any (OrgProfile) and /themes (ThemeGallery, whose Preview buttons open the theme previews; a theme's package page links there too), with a Themes entry in the main menu. Off a registry host the blocks render a placeholder and the pages still answer. The pages are cached with the tags registry and registry:<name>, and the service evicts them after every mutation (publish, review decision, deprecation, yank, advisory, publisher or namespace change), so a publish shows at once; the 60 s listing TTL is the backstop.
Theme previews
GET /registry/<name>/preview renders a listed theme as a visitor would see it, without installing it anywhere: the latest listed version's tarball is opened with the registry's package reader, its kit.json goes through the ordinary kit installer with sample content on a throwaway in-memory site (the same scratch factory the publish checks use), its tokens are applied (theme.json in the tarball in any format Brand → Import brand reads as design tokens, DTCG, CSS, Figma or Tailwind, or a theme object in kit.json with the token names of ThemeTokens), and the home page is rendered through the public renderer. /registry/<name>/preview/<page> renders the kit page at that slug (/registry/ocean/preview/contact); ?version=1.0.0 pins a version. Scoped names work as /registry/@acme/ocean/preview.
The page is self-contained: a bar at the top names the theme and version and links every kit page and the package page; internal links stay inside the preview; forms are rendered inert with no action, so nothing submits; the kit's media is served through the preview's own path from the scratch site; the admin and the API are out of reach. It is rendered as a preview (<meta name="robots" content="noindex, nofollow">, no structured data, no canonical) and answers with X-Robots-Tag: noindex.
One scratch site is built per theme version and kept warm; at most five stay warm (least recently used closed first), and each rendered page is cached for ten minutes under the registry tags (registry, registry:<name>), so a yank, a new version or a deprecation evicts it at once. Modules, kits, unknown names, versions that are not listed and pages the kit does not have answer 404. The tarball size cap of publishing applies. No screenshot is generated on the server (there is no browser dependency): the gallery and the package page keep the publisher's screenshots and link Preview here.
Operating notes
- The registry is multisite-agnostic: packages are instance-wide, not per site.
- Publishes run the checks inline (the Wasm smoke load and the kit dry run take well under a second each); a kit dry run boots a throwaway in-memory SQLite app, so the registry host needs
better-sqlite3even when its own database is Postgres. - Rate limits: 30 publishes an hour per user, 120 downloads (tarballs and
files/*) a minute per address. Put a CDN in front of/registry/v1/packages/*/download,files/*andindex.json; all are cacheable. Download-count rows (download_daily) are pruned after 400 days. - Links people type (publisher
urlandavatarUrl, advisoryurl) must be http(s);spillway publishrefuses to send its token over plain http to anything but localhost unlessREGISTRY_ALLOW_HTTP=1. - The storage directory and the database must be backed up together; the sha256 in the index and in the
registry_versionrow lets you verify restored tarballs. - Tests:
packages/core/src/test/theme-preview.test.tsrenders two pages of a published theme with its tokens, checks the 404s, the cache, the noindex headers and the scratch site LRU.packages/core/src/test/registry.test.tspublishes a sandboxed module, a kit and a theme, walks the review flow, verifies the signed index withverifyIndex, installs a module and a kit from the index into a second app instance end to end (and refuses a tampered tarball), serves tarball files, counts downloads, yanks and deprecates, attaches advisories, searches, lists publishers and namespaces, evicts cached pages on publish, serves publisher profiles and the agent manifest, publishes through the CLI client and signs in through an in-process fake GitHub.registry-orgs.test.tscovers organizations: creation and handle collisions, members and roles, publishing under an organization's namespace as admin, member and outsider, transfers, leaving, the last-owner rule, four-eyes with organizations, the public profile and pages, the verified badge and the audit rows.node e2e/smoke-registry.mjsdrives the public pages and the admin screens in a browser against aREGISTRY_HOST=1server.
Spillway 0.1.0 · Edit this page on GitHub