Implemented phase 4 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:55:35 +02:00
1 parent 95e0a455e8
commit 9403931109
42 files changed
+3407 -36

No files matched your search

+24 -7
View File
@@ -6,7 +6,8 @@ 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`, the sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)).
- 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 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).
@@ -49,8 +50,10 @@ LogRequestsMiddleware
→ 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} → rendered per request)
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)
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)
```
@@ -69,17 +72,24 @@ The screens follow the **CCN marketplace** design canvas:
- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`) — a copy sold since still shows there until the next launch.
- **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, save, 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 save and follow render disabled. A sold copy, and a seller's own listing, show no actions.
- **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's lettering and tone.
- **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 passkey ceremony is stubbed.** `POST /sign-in` trusts the email it is given, and signs in the seeded demo buyer when given none. Replace it with WebAuthn before anything real depends on it.
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.
- **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` when the site is served over HTTPS). They end with the process.
- **One step signs in and joins**: an unknown email creates the account, named after the email's local part.
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.
- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out.
- **Left out**: the first-sign-in screen (display name, backup passkey), recovery, and managing passkeys from an account screen.
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 |
@@ -93,7 +103,14 @@ The screens follow the **CCN marketplace** design canvas:
- **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, vacation mode, and new listings.
- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode.
### 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.