# CCN Website The **CCN** public website service — a [Hummingbird](https://github.com/hummingbird-project/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](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#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](#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](#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](#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](#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](#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](#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](#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](https://umami.is) tracker on both pages once a deployment configures one; it ships **off**, so an unconfigured copy requests no third-party script (see [Analytics](#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](https://github.com/hummingbird-project/hummingbird-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.3 toolchain (`swift-tools-version:6.3`). - Docker (optional) for the containerized run/deploy workflow. - The [Hummingbird](https://github.com/hummingbird-project/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: ``` 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 / → 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) AccountController (GET/POST /welcome, GET /account, POST /account/…, GET/POST /sell/open-a-shop) CollectingController (GET/POST /wants…, GET /series, GET /series/{slug}, GET /compare) 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](#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](https://github.com/swift-server/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. - **Left out**: passkey recovery. 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. ### 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. - **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](https://github.com/vapor/multipart-kit). 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` | `` | The page's one-line description. | | `canonicalURL` | `` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). | | `socialCard` | Open Graph + Twitter `` 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` | `