Files

CCN Website

The CCN public website service — a Hummingbird server that renders the CCN marketplace prototype and serves the site's static assets.

Overview

The service:

  • Serves the landing page at GET / (rendered once per supported language with Elementary and cached), plus one prefixed route per non-default catalog language — GET /nl and so on (see Language editions).
  • Serves the public catalogue, rendered per request: search results at GET /search (filtered through its query parameters), a listing at GET /listing/{slug}, and a seller's shop at GET /shop/{slug}; an unknown slug gets the not-found page (see Catalogue).
  • Serves the path to a sale, for signed-in accounts: the offer sheet, the messages, the sale records, and the seller dashboard (see Path to a sale).
  • Serves the gate: the sign-in screen at GET /sign-in, its passkey ceremonies under POST /sign-in/passkey/…, the demo sign-in at POST /sign-in, and the sign-out at POST /sign-out (see Sign-in).
  • Serves what an account does for itself — its first sign-in, its profile and passkeys, opening a shop — and what a collector uses: the want list, the series screens, and the comparison of copies (see Accounts and collecting).
  • Serves the public grading guide at GET /grading-guide, and the new-listing form at GET/POST /sell/new for the owner of a shop (see Selling).
  • Negotiates each request's language from the lang query parameter, the leading path segment, then its Accept-Language header, against the languages in the WebsiteLibrary String Catalog, falling back to the default (en) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with Content-Language and Vary: Accept-Language headers.
  • Builds every page on the shared Page scaffolding from Infrastructure, which assembles the document head around the page's own markup: the viewport declaration, the optional description summary and rel="canonical" link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see Page metadata).
  • Answers a liveness check at GET /health with a static JSON payload, and a readiness check at GET /health/ready that reports whether the database is reachable (200 ready / 503 unavailable).
  • Answers HEAD on every GET route: the router is built with .autoGenerateHeadEndpoints, so uptime monitors and crawlers probing with HEAD get the route's status and headers instead of a 404.
  • Serves static files (CSS, JS, icons, manifest, robots.txt, sitemap.xml) from Resources/Static via Hummingbird's FileMiddleware, tagged with media-type-specific Cache-Control; the production image ships minified copies (see Static assets).
  • Returns a custom not-found (404) HTML page, localized like the landing page, for any request that matches neither a route nor a static file.
  • Embeds a cookieless Umami tracker on both pages once a deployment configures one; it ships off, so an unconfigured copy requests no third-party script (see Analytics).
  • Serves the pages gzipped once at startup, sized rather than chunked, and compresses everything else (gzip/deflate) above a configurable size when the client advertises support.
  • Stamps a hardened set of security headers on every response.
  • Persists data through Fluent, against either an ephemeral in-memory SQLite database (the default — no external infrastructure) or a PostgreSQL server, selected by a single configuration key.

Requirements

  • Swift 6.4 toolchain (swift-tools-version:6.4).
  • Docker (optional) for the containerized run/deploy workflow.
  • The Hummingbird CLI (hb) — optional, only for make site-run (watch and rebuild on change).

Architecture

Two SwiftPM targets:

Target Kind Path Role
Website executable Sources/App Entry point: reads configuration, builds the persistence service, and either serves the website or runs the migrate-and-exit mode.
WebsiteLibrary library Sources/Library Controllers, the pages (IndexPage, NotFoundPage) and their shared Page defaults, the StaticFile asset catalog, the request context, the String Catalog, and the *+Defaults extensions and configuration-key constants that supply the site's specifics to Infrastructure.

The Website executable depends on four local packages, each under Packages/:

  • Localization — the Localize and Negotiate helpers and the LanguageList of catalog languages (used by WebsiteLibrary).
  • Infrastructure — the shared Hummingbird toolkit: the RouterController protocol and addController result-builder extension for declarative routing, the security, redirect, vary, compression, rate-limit, and not-found middlewares, the Negotiate(for:) request-language extension, the Page and Asset scaffolding, the SocialCard/StructuredData/Analytics head-metadata types, the pre-rendered localized HTML responses, and the FingerprintAssets version-token derivation. The service supplies its specifics (String Catalog bundle, pages, icon metadata, analytics) through the *+Defaults extensions in WebsiteLibrary and the ConfigReader properties in the executable.
  • Persistence — the Fluent-based data layer: the Driver selector, the Service factory that builds the Fluent service, the PrepareDB registrar that declares the migrations, and the Probe consulted by the readiness check; the models, migrations, and repositories stay internal to the package. It has no dependency on swift-configuration; the executable maps the database.* keys onto the driver.
  • Utility — small shared helpers with no server dependencies, currently the NormalizeEmail method.

The persistence backend runs as a Fluent service inside the application's ServiceLifecycle group, so it starts and stops alongside the HTTP server (which owns its connection-pool shutdown on graceful termination).

Requests pass through the middleware chain in this order (outermost first), then reach the routes:

NoIndexMiddleware                        (X-Robots-Tag: noindex on every response, unless site.indexable)
LogRequestsMiddleware
  → SecurityHeadersMiddleware            (security headers on every response)
    → HTTPSRedirectMiddleware            (301 to site.origin when forwarded over plain HTTP)
      → TrailingSlashRedirectMiddleware  (301 to the path without a trailing slash)
        → VaryMiddleware                 (marks every response as varying on Accept-Encoding)
          → CompressionMiddleware        (gzip/deflate above the size threshold; pre-compressed pages pass through)
            → NotFoundMiddleware          (negotiates the language, renders the not-found page on .notFound)
              → FileMiddleware            (serves Resources/Static)
RootController                            (GET / → landing page, language negotiated; GET /<lang> → its other editions)
CatalogueController                       (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request)
SessionController                         (GET /sign-in, POST /sign-in, POST /sign-out, GET/POST /recover, GET /recover/{token})
AccountController                         (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop)
CollectingController                      (GET/POST /wants…, GET/POST /collection, GET /series…, GET /compare, GET /events)
PasskeyController                         (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON)
ListingFormController                     (GET /sell/new, POST /sell/new → multipart, for a shop's owner)
TradeController                           (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
HealthController                          (GET /health → liveness, GET /health/ready → readiness)

The router is created with .autoGenerateHeadEndpoints, so each of those GET routes gets a HEAD sibling for free.

Design foundation

The screens follow the CCN marketplace design canvas:

  • Tokens and primitives live in Resources/Static/css/shared.css, written phone-first with breakpoints at 600px and 1024px; the dark theme only repoints the tokens.
  • No inline styles: the Content-Security-Policy blocks them, so markup carries classes only.
  • Shared frame: SiteHeader, SiteFooter, and the SiteLayout a page wraps its content in, under Sources/Library/Internal/Components.
  • Paths: String.Route lists every screen's path; screens not built yet answer with the not-found page.

Catalogue

  • Data: Persistence's CatalogueRepository reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
  • Landing page: rendered once and cached (HomePages), and rendered again whenever a sale or a new listing changes the catalogue — so its entity tag changes with it.
  • Search and shop: SearchParameters turns the query string into a ListingFilter and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain GET forms and links. Searches default to listings shipping from the EU.
  • Gated actions: signed out, buy, offer, message, and follow link to the sign-in screen with a return parameter; signed in, buy, offer, and message lead on (see Path to a sale), while follow renders disabled. Saving a copy adds a want for it. A sold copy, and a seller's own listing, show no actions.
  • Covers: a listing shows its first photo, or — like every seeded listing — a placeholder Cover drawn from its lettering and tone.
  • Scripts: js/shared.js only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it.

Sign-in

The sign-in screen offers two ways in:

  • A passkey (PasskeyController, js/passkey.js): real WebAuthn, through webauthn-swift. An unknown email registers a discoverable passkey and creates its account in the same step; after that the passkey signs in with or without the email. A passkey is never added to an existing account — the prototype has no other proof of who is asking, so that would hand the account over.
  • The demo sign-in (SessionController): stubbed. It trusts the email it is given, and signs in the seeded demo buyer when given none. It exists because the seeded accounts have no passkey; remove it before anything real depends on sign-in.

How it holds together:

  • Origin: browsers bind a passkey to the page's origin, so passkeys.origin (PASSKEYS_ORIGIN) is configuration, never read off a request. It defaults to http://localhost:8080; WebAuthn only runs on HTTPS or on localhost — not 127.0.0.1.
  • Ceremonies: PasskeyCeremonies keeps each challenge in memory for five minutes, under a token in a SameSite=Strict cookie scoped to /sign-in, and hands it over once.
  • Sessions: SessionStore keeps them in memory, keyed by a random token in the ccn_session cookie (HttpOnly, SameSite=Lax, and Secure over HTTPS). They end with the process.
  • Return to the intent: the return parameter is followed only when it is a rooted path on this site; anything else returns home.
  • Per-viewer pages: the controllers read the viewer from the request and send those pages Cache-Control: private, no-cache. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
  • Adding a passkey happens from the account screen alone: the account's own session is the proof of who is asking.
  • Sign-out is a POST form on the account screen, so a link on another site cannot trigger it. The not-found page always renders signed out.
  • Recovery: /recover hands out a one-time link (RecoveryLinks: in memory, good once, for an hour); following it runs a WebAuthn registration that replaces every passkey on the account and signs in. Stubbed: with no mail to send, the link is shown on the screen — so anyone who knows an email can recover its account. The design's 24-hour hold, and the cancel from another device, are not built.
  • States: a cancelled passkey prompt says so, and a browser without WebAuthn — or without scripts — is told it can't use passkeys.

The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in swift test: Tests/Browser/passkeys.py drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands).

Path to a sale

Route Screen
GET/POST /listing/{slug}/offer The offer sheet: an amount, one-tap picks (plain links, so they work without scripts), and a note.
GET/POST /listing/{slug}/buy The same sheet, confirming a purchase at the asking price.
GET /messages, GET/POST /messages/{id}, GET /messages/new The conversations rail and the open conversation: messages, offer cards, and the events of the sale.
GET/POST /sales/{number} The sale record: what was agreed, frozen, and the five-step handshake.
GET /sell, POST /sell/offers/{id} The seller dashboard and the answer to an offer.
  • The rules live in Persistence: TradeRepository decides who may offer, answer, or take a step; TradeController only maps a refusal to the sheet re-rendered with a 422, or to a redirect that changes nothing.
  • Privacy: a conversation or sale the account is no party to is not found, rather than forbidden.
  • Demo accounts: every seeded shop has an owner; sign in as joost@kestrelrow.example to sell for Kestrel Row Comics.
  • Left out: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode. Terms, privacy, and seller rules have no screens: the design has none to build them from.

Accounts and collecting

  • First sign-in: a new account — joined by passkey or through the demo sign-in — meets /welcome once, picks its display name, and goes on to what it came for.
  • Account: the display name, the passkeys with a way to add and revoke them, and the sign-out. Notifications and language settings are left out: nothing stands behind them yet.
  • Open a shop: one form — the shop's public identity, private or business seller (a business states its KvK number, as Dutch consumer law asks), and policies — then straight on to the first listing. A new shop starts unverified and unrated.
  • Want list: a want is words a listing's title must carry, within a grade and a price, among copies shipping from the EU. Matches listed since the list was last looked at are new, and ring the header's bell. Saving a copy, and a gap in a series, both fill the want form.
  • Series: Persistence seeds a small catalogue of runs, which is what lets a gap show: every issue of the run, the cheapest copy of each, and alert me where none is listed. A series sellers list but the catalogue lacks is made up from its listings, without gaps.
  • Collection: the issues an account owns of each series, added from a form (1-6, 9, 12) or a switch per issue on the series screen — which then dims what is owned and counts the gaps listed right now.
  • Events: a seeded calendar of cons and the shops trading at each. A seller picks theirs on the dashboard; a listing and its shop say where a copy can be picked up; and pickup= narrows a search to the shops at one event, or at any.
  • Compare: up to three copies of an issue, ordered by what each costs delivered to the Netherlands, flagging a customs border.

Every link on the site leads somewhere: Tests/Browser/links.py crawls them all — signed out, as a buyer, and as a seller — against a running service.

Selling

  • One vocabulary: Grading holds the scale, the conditions, and the eight-box defect checklist. The grading guide prints it, the form offers it, and ListingForm applies it — a colour-breaking crease caps a raw book at 7.0, and case scuffs are a slab's alone.
  • The form is the data model: format, catalogue fields, slabbed or raw, grade, declared defects, photos, price. It posts as multipart/form-data, parsed with MultipartKit. Without scripts every field shows, labelled by what it applies to; with them, js/shared.js hides the rest.
  • Declared defects show on the listing and are frozen into a sale's declaration at agreement.
  • Photos: up to four, 3 MB each, told apart as JPEG, PNG, or WebP by their leading bytes — not by name or declared type — and stored under names of the store's own in Resources/Static/uploads, which FileMiddleware serves. The fields are checked first, so a form sent back leaves nothing on disk.
  • Prototype limits: photos are stored as posted — not re-encoded, so they keep their metadata — on the instance's own disk; the container image would need that folder writable. The design's catalogue match, live preview, drafts, and price guidance are left out.

Page metadata

Each page conforms to Infrastructure's Page protocol and supplies only its title, content, stylesheets, and scripts; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the analytics origin preconnect hint, the summary, the canonicalURL link, the socialCard tags, the structuredData script, the analytics tracker script, then the page metadata and the stylesheet links. The body is the content followed by the script tags.

Four of those are page-authored and optional. IndexPage supplies canonicalURL; the other three are omitted by default — the reference site leaves them unset, so a generated site fills in what it needs by overriding them on IndexPage (or on the shared Page+Defaults extension, for site-wide values):

Property Renders as Notes
summary <meta name="description"> The page's one-line description.
canonicalURL <link rel="canonical"> Absolute URL. IndexPage derives it from site.origin and the page's own language; an unset origin omits it (see Language editions).
socialCard Open Graph + Twitter <meta> tags A SocialCard — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; Page+Defaults supplies the locale as ogLocale.
structuredData <script type="application/ld+json"> A StructuredData graph of schema.org nodes; StructuredData(name:url:logo:inLanguage:profiles:) builds the site-wide Organization + WebSite pair, inLanguage declaring the languages the site publishes in (see Language editions). The payload is an inert data block, so the Content-Security-Policy does not apply to it. Requires Infrastructure's StructuredData trait, which Package.swift enables.

The fifth, analytics, is configuration-authored rather than page-authored: the executable builds an Analytics from the analytics.* keys and hands it to RootController and NotFoundMiddleware, which pass it to both pages. It renders as a <link rel="preconnect"> plus a deferred <script> carrying the Umami data- attributes, and — unlike the structured data — it is executable, so the Content-Security-Policy must allow its origin. It is empty by default; see Analytics for how to turn it on.

What the pages do ship is in Page+Defaults (Sources/Library/Internal/Extensions), shared by every page: the document lang, the ogLocale a social card would carry, the preloadedFonts links, the favicon / SVG icon / apple-touch-icon / web-manifest links, and the two theme-color metas. The last group lives in siteMetadata, which metadata returns unchanged — a page that adds head tags of its own composes siteMetadata rather than replacing it.

ogLocale renders Open Graph's language_TERRITORY form by looking the page's lang up in the ogLocales map, which ships one pairing: en → en_US. A site serving a language in a territory of its own repoints or extends the map (en_NL, nl_NL, …); a language with no entry stays a bare code, which scrapers also accept. Nothing reads it until a page supplies a socialCard.

preloadedFonts lists the three self-hosted faces under Resources/Static/font: Archivo, Oswald, and Anton. Listing one emits <link rel="preload" as="font" crossorigin> at /font/<name>.woff2, deliberately unversioned: a preload URL must match the stylesheet's @font-face source exactly, or the browser fetches the font twice. List only the faces the stylesheets actually render — a subset gated by a unicode-range no page reaches would add a download that never otherwise happens.

Language editions

The WebsiteLibrary String Catalog is the single source of truth for the languages the site serves: add a localization and it appears, with no code change. Language (Sources/Library/Internal/Types) reads that list and derives each language's URLs from it.

The catalog's source language is the default and owns the site's bare paths; every other language answers under a prefix of its own. The root collapses onto the prefix alone, so a Dutch home is /nl, not /nl/ — the spelling TrailingSlashRedirectMiddleware redirects away from anyway:

Language Landing page A page at /privacy
en (default) / /privacy
nl /nl /nl/privacy

RootController registers the bare route plus one per non-default language. A prefixed route answers in its language for every visitor and every crawler — the path is the language choice, so the negotiated context language is ignored, which is what lets a search engine index it as that edition. The template ships an English-only catalog, so it registers the bare route alone.

Given a site.origin, each page then emits the hreflang alternates tying its editions together — one per language plus an x-default pointing at the default language's edition, whose bare URL negotiates the language and so is the right landing for everyone unmatched. A single-language site emits none: a set naming one edition tells a search engine nothing it cannot already see. Page+Defaults' languageAlternates(origin:path:languages:) takes the language set, so a page translated into only some of them narrows it rather than advertising an edition that does not exist.

The same set belongs in the site-wide structured data: StructuredData's inLanguage declares it on the WebSite node, so a crawler reads the site's languages from the graph as well as from the alternates. A page's social card says the same to a scraper: its locale is the edition the page is, and SocialCard's alternateLocales names the rest, each mapped through ogLocales the way ogLocale maps the page's.

sitemap.xml is the one part that does not follow the catalog: it is a static file, so a new language needs its editions added by hand — /nl, /nl/<page>, one <loc> each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.

Visitors switch language two ways, both read by Infrastructure's Negotiate(for:): a ?lang= query parameter (what a language switcher links to) and the leading path segment. Either beats Accept-Language; a value naming no supported language is ignored. Only the responders that need the answer ask — RootController's bare route and NotFoundMiddleware — so no request pays for a negotiation it never reads. The path segment matters beyond the routed pages — it is what makes an unrouted path under a language's prefix answer its not-found page in that language.

Configuration

Configuration is read through swift-configuration, highest precedence first:

  1. Command-line arguments (e.g. --http-host 0.0.0.0)
  2. Process environment variables
  3. A .env.local file in the working directory (optional)
  4. A .env file in the working directory (optional)
  5. Built-in defaults

The two files play different roles:

  • .env (git-ignored) holds your deployment values — including the database password — and is the file the Makefile and Compose read for their ${VAR} placeholders; it typically selects the PostgreSQL backend. Keep it out of version control and off shared machines: Compose passes its values to the container as environment variables, so anything in it is readable through docker inspect and by every process in the container.
  • .env.local (tracked) holds the local development overrides: in-memory database, debug logging. Sitting above .env, it keeps a direct launch (swift run or a debugger) on the local values even when .env points at a deployment. Compose never reads it, and the production image does not ship it.

The Makefile includes .env and exports every value, so a target launched through make runs with the deployment configuration rather than the .env.local one: make site-run uses the backend .env selects, a bare swift run Website the in-memory one. And because a makefile assignment outranks an inherited environment variable, overriding a value for a single invocation takes a command-line variable after the target (make site-mount DATABASE_DRIVER=postgres) — an environment prefix is silently discarded.

Environment variable naming

A dotted config key maps to an environment variable by upper-casing, splitting camelCase, and replacing separators with _. For example http.serverName → HTTP_SERVER_NAME, security.strictTransportSecurity → SECURITY_STRICT_TRANSPORT_SECURITY, cache.maxAge.text → CACHE_MAX_AGE_TEXT.

To disable a header or override a value, leave the variable unset to fall back to the default. A variable that is set but blank is treated as an explicit empty value, not as "use the default".

Static file caching

Config key Environment variable Default Description
cache.maxAge.asset CACHE_MAX_AGE_ASSET 31536000 (1 year) max-age for the fingerprinted assets (CSS, JS, MP4, JPEG, WebP) and fonts; also marked immutable. The pages reference them through content-versioned URLs (?v=<token>), so a deploy busts them by changing the URL. Fonts are immutable subset files, preloaded unversioned.
cache.maxAge.text CACHE_MAX_AGE_TEXT 3600 (1 hour) max-age for the remaining text/* assets (e.g. robots.txt), which keep unversioned URLs; also marked must-revalidate.
cache.maxAge.image CACHE_MAX_AGE_IMAGE 604800 (1 week) max-age for the remaining images — the icons (ICO, PNG, SVG), which a browser fetches unversioned whatever the markup says, so they cannot be immutable.
cache.maxAge.default CACHE_MAX_AGE_DEFAULT 86400 (1 day) max-age for everything else — including site.webmanifest (application/manifest+json) and sitemap.xml (application/xml), neither of which is text/*.

The groups are matched in order, so the specific media types (text/css, text/javascript, font/*, video/mp4, image/jpeg, image/webp) resolve before the general text/* and image/* categories.

An image the markup references without a ?v= token must be neither JPEG nor WebP, or it is served immutable for a year and no deploy can dislodge it.

Response compression

Config key Environment variable Default Description
compression.minimumResponseSize COMPRESSION_MINIMUM_RESPONSE_SIZE 1024 Minimum response body size, in bytes, before compression is applied.

The pages are gzipped once at startup by CachedHTMLResponse and served with a Content-Length, so the threshold governs everything else. CompressionMiddleware wraps Hummingbird's ResponseCompressionMiddleware and leaves a response that already names an encoding alone — the upstream middleware would append a second gzip.

HTTP server

Config key Environment variable Default Description
http.host HTTP_HOST none Host the server binds to. Supplied via the --http-host CLI flag (the Docker image passes 0.0.0.0).
http.port HTTP_PORT none Port the server listens on. Supplied via the --http-port CLI flag (the Docker image passes 8080).
http.serverName HTTP_SERVER_NAME CCNWebsite Server name and logger label.

HTTPS redirect

Config key Environment variable Default Description
https.trustForwardedProto HTTPS_TRUST_FORWARDED_PROTO false Read the visitor's scheme from the X-Forwarded-Proto header and answer the plain-HTTP ones with 301 Moved Permanently to the same path on site.origin. Enable only behind a reverse proxy that sets the header — it is the sole trigger. Pairs with rateLimit.trustForwardedFor: both assert a reverse proxy, so set them together, as the Compose files do.

Redirecting collapses the http:// and https:// copies of every page onto one address, which is what a search engine consolidates a site's signals against. Three details:

  • 301, not 302 — a temporary redirect keeps the HTTP URLs indexed. Browsers cache it for a long time, so settle the target first.
  • Target built from site.origin, not the Host header — a client cannot steer it. An origin that is not itself HTTPS disables the middleware instead of looping.
  • /.well-known/ is exempt — redirecting the ACME challenge path breaks certificate renewal.

docker-compose.yml enables it for production; docker-compose.override.yml pins it off for local development.

Trailing slashes are canonicalized separately and unconditionally, with no configuration key: the router matches /about and /about/ alike, so every GET/HEAD whose path ends in a slash is answered with a 301 to the form without one (// collapses to /; / is left alone). The Location is relative, so it keeps the request's own scheme and host. Other methods pass through, since a client may repeat a redirected POST as a GET and drop the body.

Site

Config key Environment variable Default Description
site.indexable SITE_INDEXABLE false Whether search engines may index the site. While false, NoIndexMiddleware marks every response — pages, files, redirects, errors — X-Robots-Tag: noindex, nofollow. robots.txt stays open on purpose: a crawler barred from a page never reads its noindex, and may still list the bare URL. Before turning it on, restore the Sitemap: line and the sitemap's entries.
site.origin SITE_ORIGIN (set by bootstrap) The public origin the site is served at (scheme and host, no trailing slash). The HTTPS redirect points at it, and the pages' rel="canonical" links and hreflang alternates derive from it.

Bootstrap writes the canonical URL it prompts for here, leaving it empty for the placeholder. An empty or non-HTTPS origin disables the HTTPS redirect, which https.trustForwardedProto must enable besides — a 301 is cached for a long time, so it is never issued at a host nobody named. An empty origin also leaves the pages without canonical URLs and language alternates, rather than building either against an empty host.

Logging

Config key Environment variable Default Description
log.level LOG_LEVEL info Minimum log level.

Persistence

Config key Environment variable Default Description
database.driver DATABASE_DRIVER inMemory Backend: inMemory (ephemeral SQLite, no infrastructure) or postgres (PostgreSQL). Any other value fails the boot.
database.migrate DATABASE_MIGRATE (flag --database-migrate) false When set, run the migrations and exit instead of serving.
database.host DATABASE_HOST localhost PostgreSQL host. Ignored for inMemory.
database.port DATABASE_PORT 5432 PostgreSQL port. Ignored for inMemory.
database.name DATABASE_NAME ccn Database name. Ignored for inMemory.
database.username DATABASE_USERNAME ccn Database username. Ignored for inMemory.
database.password DATABASE_PASSWORD (empty) Database password. Provide via the environment/a secret — never commit it.
database.tls DATABASE_TLS prefer TLS posture when connecting: off, prefer, or require. Any other value fails the boot. Ignored for inMemory.
database.pool.maxPerEventLoop DATABASE_POOL_MAX_PER_EVENT_LOOP 4 Maximum pooled connections per event loop. Ignored for inMemory.
database.pool.timeout DATABASE_POOL_TIMEOUT 10 Seconds a query waits for a pooled connection before failing. Ignored for inMemory.

Unrecognized tokens fail the boot. Neither database.driver nor database.tls falls back, because both fallbacks are silent and costly: an unrecognized driver would run on the ephemeral in-memory database and discard every write on restart, and an unrecognized posture would land on prefer, which hands the password over in plaintext when the upgrade is stripped. The thrown ConfigError names the tokens the key accepts.

Connection budget: the pool holds database.pool.maxPerEventLoop connections per event loop, and the event loop group runs one loop per core. An 8-core instance can therefore open 32, and each replica that many again — three replicas exhaust PostgreSQL's default max_connections of 100. Size this against the server's limit, not against the number alone. On an exhausted pool, a query waits up to database.pool.timeout before failing.

See Persistence below for the workflow.

Paths

Config key Environment variable Default Description
path.staticFiles PATH_STATIC_FILES Resources/Static Directory, relative to the working directory, that static files are served from.

Rate limiting

Config key Environment variable Default Description
rateLimit.limit RATE_LIMIT_LIMIT 5 Requests admitted per client per window; the excess is answered with 429 Too Many Requests and a Retry-After header.
rateLimit.window RATE_LIMIT_WINDOW 60 Window length, in seconds, the limit applies to.
rateLimit.trustForwardedFor RATE_LIMIT_TRUST_FORWARDED_FOR false Key clients by the first X-Forwarded-For entry instead of the connection's address. Enable only behind a reverse proxy that sets the header — when the server is directly reachable, clients can forge it. Pairs with https.trustForwardedProto; left false behind a proxy, every visitor shares the proxy's one bucket.

Configured but unapplied. The template ships no endpoint worth limiting, so RateLimitMiddleware is built from these keys and never added to the chain. Wire it onto the route group that needs it — a form submission, say — when the site grows one; docker-compose.yml already sets rateLimit.trustForwardedFor alongside the HTTPS flag, so the buckets are keyed per visitor from the first deploy.

Analytics

The template ships analytics off: analytics.websiteID is empty, so both pages embed no tracker at all and no third-party script is requested. Enabling it takes three steps, in this order:

  1. Check String.Analytics.origin (Sources/Library/Public/Extensions/String+Constants.swift). It ships as https://analytics.rock-n-code.com, the platform's shared Umami instance; point it elsewhere if this site reports to another one. The origin alone tracks nothing — the tracker is omitted entirely while analytics.websiteID is empty.
  2. Extend security.contentSecurityPolicy to allow that origin in script-src and connect-src — the default policy is 'self'-only, so the tracker is blocked until you do.
  3. Set ANALYTICS_WEBSITE_ID on the deployment.
Config key Environment variable Default Description
analytics.websiteID ANALYTICS_WEBSITE_ID (empty — analytics off) The analytics website identifier the tracker on both pages reports as. While it is empty the tracker script is omitted entirely; clearing it again disables analytics on a deployment.
analytics.domains ANALYTICS_DOMAINS (empty — every host reports) Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. Left empty, the attribute is omitted and no host is filtered out.
analytics.recorder ANALYTICS_RECORDER false Whether the pages also embed the session recorder script (recorder.js, loaded from the tracker's origin) alongside the tracker. Session recording is the most invasive thing the tracker does, so it is opted into: set it to true to enable it on a deployment.

The tracker's origin is not a configuration key: it is single-sourced in code so the tracker tag and the Content-Security-Policy that must allow it (security.contentSecurityPolicy below) cannot drift apart at runtime. The pages emit a preconnect hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script.

Set analytics.domains to the host the deployment actually serves, or leave it empty. It is an allowlist: name a host the deployment does not serve (say, pointing a staging box at the production domain) and every visit is dropped silently, with no error. To turn analytics off instead, clear analytics.websiteID.

Security headers

Config key Environment variable Default
security.contentSecurityPolicy SECURITY_CONTENT_SECURITY_POLICY default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
security.contentTypeOptions SECURITY_CONTENT_TYPE_OPTIONS nosniff
security.frameOptions SECURITY_FRAME_OPTIONS DENY
security.referrerPolicy SECURITY_REFERRER_POLICY strict-origin-when-cross-origin
security.permissionsPolicy SECURITY_PERMISSIONS_POLICY accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
security.strictTransportSecurity SECURITY_STRICT_TRANSPORT_SECURITY none (omitted)

Strict-Transport-Security has no default and is omitted unless explicitly configured: browsers ignore it on plain HTTP but remember it stickily once seen, so it must stay off in local HTTP development. docker-compose.yml enables it for production, where it takes effect once traffic is served over HTTPS behind a TLS-terminating proxy. https.trustForwardedProto carries the same caveat: a cached 301 is as sticky as an HSTS commitment.

Running locally

Directly with Swift:

swift run Website # binds to Hummingbird's default 127.0.0.1:8080
swift run Website --http-host 0.0.0.0 --http-port 9000 --log-level debug

A direct run picks up the .env.local development overrides (in-memory database, debug logging) over whatever .env configures. To run against another backend, override per launch — DATABASE_DRIVER=postgres swift run Website — since process environment variables outrank both files.

Or via the Makefile / Docker (uses docker-compose.override.yml, which builds from source and sets LOG_LEVEL=debug):

make pkg-build        # swift build
make pkg-outdated     # list the SPM dependencies that can be updated
make pkg-update       # update the SPM dependencies
make site-run         # run locally with hot reload (hb watch)
make site-mount       # docker compose up --build --detach
make site-unmount     # docker compose down + remove the local image

Unlike a direct swift run, these targets inherit the exported .env values (see Configuration), so they run against whichever backend .env selects. The one exception is DATABASE_HOST under make site-mount: the local Compose override pins it to the postgres service name, since the .env value addresses the database from the host rather than from inside the container. make help lists every available target.

Persistence

The service persists data through Fluent and selects its backend at runtime with database.driver.

In-memory (default)

With no configuration, the service uses an ephemeral in-memory SQLite database. It is created, migrated, and seeded with the demo catalogue on every launch, so swift run Website and docker compose up work with no external database — ideal for local development and tests.

PostgreSQL

Set DATABASE_DRIVER=postgres and the connection values (DATABASE_HOST, DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, …). Unlike the in-memory backend, PostgreSQL is not migrated on boot — a shared database is migrated out of band so multiple instances never race:

# Run the registered migrations, then exit.
swift run Website --database-migrate
# The same, in a container against the managed database.
docker compose -f docker-compose.yml run --rm website --database-migrate

A local PostgreSQL for development lives behind the database Compose profile, so a plain docker compose up still runs in-memory. Its data directory is bind-mounted to Tests/DB (git-ignored) and initialised once from the DATABASE_NAME/DATABASE_USERNAME/DATABASE_PASSWORD values in .env:

make db-mount     # start PostgreSQL (docker compose --profile database up --wait postgres)
make db-migrate   # migrate it, from the host (forced to 127.0.0.1 with TLS off)
make db-shell     # open a SQL shell on it (psql)
make db-unmount   # stop and remove the container, keeping the data
make db-reset     # stop and remove the container, and delete the data

Then run the site against it, on the host or in its container:

make site-run                              # hot reload on the host, via localhost:5432
make site-mount                            # containerised, via the Compose service name
curl -i localhost:8080/health/ready        # 200 once the database is reachable

The containerised run needs no DATABASE_HOST: docker-compose.override.yml pins it to postgres, the Compose service name, which is the only address that resolves from inside the network. The .env value is the host machine's view (localhost) and is left to make site-run, which does run on the host. The override also depends_on the database, so with the database profile enabled the website waits for PostgreSQL to pass its healthcheck; without the profile it still starts alone on the in-memory backend.

Note: every other DATABASE_* override must be passed after the target — DATABASE_TLS=off make site-mount is silently discarded (see Configuration), make site-mount DATABASE_TLS=off is not.

Note: db-reset deletes Tests/DB itself, because Compose's --volumes flag cannot clear a bind mount. Use it to start from an empty database — for instance after changing DATABASE_PASSWORD, which is only read when the cluster is first initialised.

Health checks

GET /health is a liveness check (process is up, no dependency check). GET /health/ready runs SELECT 1 against the database and returns 200 when reachable or 503 otherwise — so an orchestrator restarts on liveness failure but only withholds traffic on readiness failure. A hanging database is reported as not ready within the probe's 2-second deadline, so the route itself never stalls. docker-compose.yml points the website container healthcheck at /health, keeping container health decoupled from database reachability.

Testing

make pkg-test   # = swift test --disable-xctest --enable-code-coverage --enable-swift-testing --parallel

Tests use the Swift Testing framework. make pkg-test runs the service package's own two targets: WebsiteTests (the executable/integration tests) and WebsiteLibraryTests (the library unit tests).

Tests/Website.xctestplan — the plan the CCN.xcodeproj scheme runs — adds the vendored packages' suites on top of those two: InfrastructureTests, PersistenceTests, LocalizationTests, and UtilityTests. From the command line, each of those is run from its own package directory (swift test in Packages/<Name>).

The Persistence package also has its own suite, run from Packages/Persistence. It uses the in-memory backend by default; the PostgreSQL integration test is skipped unless POSTGRES_TEST_HOST points at a database, so swift test stays runnable without one:

cd ../../Packages/Persistence && swift test                              # in-memory only
cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test # against make db-mount

POSTGRES_TEST_NAME/USERNAME/PASSWORD each default to ccn (and POSTGRES_TEST_PORT is optional), so pass the password explicitly when the local container was initialised with a different DATABASE_PASSWORD.

Deployment

The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root hummingbird user, and exposes port 8080.

IMAGE_PLATFORM is the single source of truth for the deployment architecture: make img-check and make img-release both build for it, and docker-compose.yml runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow BUILD_PLATFORM (see docker-compose.override.yml), since they target your machine rather than the deployment.

The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under Tests/DB out of the build context entirely (the root .dockerignore excludes **/Tests/DB for the same reason).

Base images

All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: refresh the digests deliberately, on whatever cadence you patch on, with

docker buildx imagetools inspect swift:6.4-noble --format '{{.Manifest.Digest}}'

Both build stages are discarded — only ubuntu:noble ships — and the build and run stages each apt-get dist-upgrade, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.

The executable is the ENTRYPOINT and its serving flags are the CMD:

ENTRYPOINT ["./Website"]
CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]

The split is what makes the migrate-and-exit invocation below work: docker compose run --rm website --database-migrate replaces the CMD flags without having to override the entrypoint.

make img-check                  # verify it builds for IMAGE_PLATFORM, without tagging or publishing
make img-release version=1.2.3  # build, tag, and push a release (an explicit version is required)

Pull and run the prebuilt image in production — the -f docker-compose.yml flag is important, as it skips the local-development override:

docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d

Static assets

Resources/Static holds the site's stylesheets and scripts under css/ and js/, paired by name: shared.* is loaded by every page, alongside a per-page index.* and not-found.*. The remaining files — the icons, site.webmanifest, robots.txt, and sitemap.xml — sit at the root.

Each extension resolves to its own folder (css/, js/, img/, font/, video/), which a StaticFile overrides with in: when it needs one of its own — imagery conventionally sits in a folder per page (img/index). img/ ships empty. Every one of them is a constant of the StaticFile structure, which is what the pages derive their URLs from.

The image build optimizes the files under Resources/Static in its assets stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:

  • CSS and JS are minified with esbuild (every file in css/ and js/).
  • PNG images are losslessly recompressed with oxipng, recursively — the output is pixel-identical, only encoded smaller.
  • SVGs are minified with svgo, recursively.

The PNG and SVG passes walk the tree (--recursive), so images added in a subdirectory are optimized without touching the Dockerfile.

Files keep their names and paths, so the URLs derived from the StaticFile constants are unaffected. The repository sources stay readable and unminified: a direct swift run serves them as-is, while any image build — including the local make site-mount one — serves the optimized copies. Assets are copied from the assets stage after the binary is built, so editing a CSS/JS/image file does not invalidate the release build cache.

Preview the optimized output locally (Docker only, writes to the git-ignored .build/minified):

make ast-minify

Crawler files

robots.txt and sitemap.xml need an absolute origin, which the template ships as the placeholder https://site.example.com — an RFC 2606 reserved domain, so an un-bootstrapped copy can never point a crawler at a real site. The bootstrap script prompts for the canonical site URL and rewrites both files with it; it warns if the placeholder is left in place.

To change the origin later, edit the Sitemap: line in robots.txt and the <loc> entries in sitemap.xml. Add a <loc> per public page as the site grows — nothing generates the sitemap at runtime.

Still manual: site.webmanifest ships empty name / short_name fields; bootstrap does not fill them in.

Icons

All icons are renditions of the star mark in icon.svg, the canonical source — there is no external design file to regenerate from.

File Size Used by Dark mode
icon.svg vector Tab icon in modern browsers (preferred over the ICO) Adapts: an embedded prefers-color-scheme style flips the accent paths from #000 to #f3ecf5.
favicon.ico 32×32 Tab icon in browsers without SVG favicon support (Safari) Theme-neutral by design: it is the orange star without the accent paths, so one raster reads on both themes. Keep it accent-free when regenerating.
icon-192.png, icon-512.png 192/512 site.webmanifest install icons and splash screens None (no platform mechanism); full artwork, light rendering, on a transparent background.
apple-touch-icon.png 180×180 iOS home-screen bookmarks None (fetched once, outside any page context); full artwork, deliberately opaque — iOS fills transparent regions with black.

The icon and manifest links come from the shared Page+Defaults extension, so every page carries them, alongside two theme-color metas matching the site header, which stays dark in both themes: #191713 unqualified, then #0f0e0b qualified with (prefers-color-scheme: dark).

Note: a browser applies the first theme-color whose media query matches, so the unqualified light value currently wins on both themes. Put the dark, media-qualified meta first in Page+Defaults if the dark value should take effect.

The raster icons are committed binaries, regenerated from icon.svg on demand via a throwaway container (no local toolchain needed) — e.g. the 512px rendition:

docker run --rm -v "$PWD/Resources/Static:/work" alpine sh -c '
  apk add --no-cache imagemagick librsvg oxipng &&
  magick -background none -density 256 /work/icon.svg -depth 8 PNG32:/work/icon-512.png &&
  oxipng --opt max --strip safe /work/icon-512.png'

(-density scales the 192px viewBox: 96 × target ÷ 192. For favicon.ico, rasterize a star-only copy of the SVG at 32px and pack it with icotool -c --raw.)

Required variables

The Makefile and Compose files read these from .env (or the environment). Provide your own values — do not commit secrets.

Variable Used for
HOST_CONTAINER Container registry host (e.g. registry.example.com).
HOST_OWNER Registry namespace / owner.
HOST_USER, HOST_PASSWORD Registry credentials for make img-release.
IMAGE_NAME, IMAGE_TAG Image name and tag. IMAGE_TAG is the fallback for make img-release's version= argument.
IMAGE_PLATFORM Deployment architecture (default linux/amd64). Drives make img-check, make img-release, and the platform: the production Compose file runs the pulled image with — keep it matched to the deployment host.
BUILD_PLATFORM Architecture of the local development build only (default linux/arm64); set it to linux/amd64 on an Intel Mac or an amd64 Linux box. Never used by the production Compose file.
HOST_PORT Host port mapped to the container's 8080 (default 8080).
LOG_LEVEL Runtime log level (default info).
HTTP_SERVER_NAME Runtime server name (default CCNWebsite).
SECURITY_STRICT_TRANSPORT_SECURITY HSTS header value (default max-age=31536000; includeSubDomains).
SITE_ORIGIN The public origin: scheme and host, no trailing slash. The local override points it at http://localhost.
SITE_INDEXABLE false by default, which keeps the whole site out of search engines.
PASSKEYS_ORIGIN The origin browsers bind passkeys to: exactly where the sign-in screen is served, over HTTPS. Follows SITE_ORIGIN unless set apart. Left at the app's default (http://localhost:8080) every ceremony fails; changed later, it strands every passkey already registered.
ANALYTICS_WEBSITE_ID, ANALYTICS_DOMAINS, ANALYTICS_RECORDER Passed through to the tracker; an empty identifier leaves it off.
DATABASE_DRIVER inMemory or postgres. The production Compose file defaults it to postgres; the local override defaults back to the in-memory backend.
DATABASE_HOST, DATABASE_PASSWORD Mandatory — the production Compose file carries no default for either, since none can be correct: localhost inside the container is the container itself, and a blank password authenticates as nobody. It refuses to start without them rather than booting a website that serves 503s. Both come from .env, which is git-ignored — never commit the password. Compose interpolates each file before merging, so both must be set for a local docker compose up too, even though the override pins the host.
DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME The rest of the PostgreSQL connection (when DATABASE_DRIVER=postgres); these do default (5432, ccn, ccn).
DATABASE_POOL_MAX_PER_EVENT_LOOP Pooled connections per event loop (default 4) — see the connection budget before scaling out.
DATABASE_TLS TLS posture: off, prefer, or require. The production Compose file defaults it to require, which refuses a server offering no TLS; the local override defaults it to off for the plaintext development container. prefer continues in plaintext when the upgrade is stripped, handing over the password — so it is not a safe production posture.

Run the migrations against the production database once before (or during) rollout: docker compose -f docker-compose.yml run --rm website --database-migrate. They seed the prototype's demo shops, listings, and accounts too.

What else a deployment needs:

  • HTTPS in front: browsers run WebAuthn nowhere else, and HTTPS_TRUST_FORWARDED_PROTO (on by default in the production Compose file) is what marks the session cookie Secure.
  • The uploads volume: the image is read-only but for Resources/Static/uploads, where the Compose file mounts website-uploads so sellers' photos outlive a redeploy.
  • Room for uploads in the proxy: a listing form carries up to 13 MB of photos — nginx allows 1 MB by default (client_max_body_size).
  • One replica: sessions, passkey ceremonies, and recovery links live in the process's memory; a restart signs everyone out.