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
+3399 -28

No files matched your search

+2
View File
@@ -59,3 +59,5 @@ fastlane/report.xml
fastlane/Preview.html fastlane/Preview.html
fastlane/screenshots/**/*.png fastlane/screenshots/**/*.png
fastlane/test_output fastlane/test_output
# Photos uploaded to listings while running locally
Services/Website/Resources/Static/uploads/
+6
View File
@@ -109,3 +109,9 @@ DATABASE_TLS=off
# Username of the database to connect as. # Username of the database to connect as.
DATABASE_USERNAME=ccn DATABASE_USERNAME=ccn
# --- Passkeys ----------------------------------------------------------------
# Origin the sign-in screen is served at; browsers bind passkeys to it.
# WebAuthn only runs on HTTPS or on localhost (not 127.0.0.1).
PASSKEYS_ORIGIN=http://localhost:8080
+16
View File
@@ -44,6 +44,14 @@ let package = Package(
url: "https://github.com/hummingbird-project/hummingbird.git", url: "https://github.com/hummingbird-project/hummingbird.git",
from: "2.25.0" from: "2.25.0"
), ),
.package(
url: "https://github.com/swift-server/webauthn-swift.git",
from: "1.0.0-alpha.2"
),
.package(
url: "https://github.com/vapor/multipart-kit.git",
from: "4.7.0"
),
.package( .package(
url: "https://github.com/adam-fowler/compress-nio.git", url: "https://github.com/adam-fowler/compress-nio.git",
from: "1.4.2" from: "1.4.2"
@@ -98,6 +106,14 @@ let package = Package(
name: "HummingbirdElementary", name: "HummingbirdElementary",
package: "hummingbird-elementary" package: "hummingbird-elementary"
), ),
.product(
name: "MultipartKit",
package: "multipart-kit"
),
.product(
name: "WebAuthn",
package: "webauthn-swift"
),
], ],
path: "Sources/Library", path: "Sources/Library",
resources: [ resources: [
+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 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 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 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. - 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)). - 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 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) → NotFoundMiddleware (negotiates the language, renders the not-found page on .notFound)
→ FileMiddleware (serves Resources/Static) → FileMiddleware (serves Resources/Static)
RootController (GET / → landing page, language negotiated; GET /<lang> → its other editions) 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) 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) TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only)
HealthController (GET /health → liveness, GET /health/ready → readiness) 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. - **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. - **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. - **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. - **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 ### 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. How it holds together:
- **One step signs in and joins**: an unknown email creates the account, named after the email's local part. - **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. - **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. - **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. - **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 ### Path to a sale
| Route | Screen | | 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. - **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. - **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. - **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 ### 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. 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.
@@ -0,0 +1,126 @@
/* CCN — grading guide. */
.guide,
.guide__section {
display: flex;
flex-direction: column;
}
.guide {
gap: var(--space-10);
}
.guide__head {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 760px;
}
.guide__scale {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: var(--space-2);
}
.guide__grade {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: var(--space-2) var(--space-1);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
}
.guide__grade strong {
color: var(--ink);
font-family: var(--font-display);
font-size: 18px;
letter-spacing: 0;
}
.guide__section {
gap: var(--space-5);
}
.guide__intro {
max-width: 720px;
color: var(--ink-soft);
}
.guide__references {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.guide__reference {
display: grid;
gap: var(--space-4);
align-items: start;
}
.guide__reference-grade {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.guide__photo {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 2 / 3;
max-width: 140px;
padding: var(--space-2);
border: 1px dashed var(--rule);
border-radius: var(--radius-sm);
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
}
.guide__reference .type-eyebrow {
margin-bottom: var(--space-2);
}
.guide__allowed {
color: var(--good);
}
.guide__excluded {
color: var(--bad);
}
.guide__defects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.guide__defect {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--space-4);
}
@media (min-width: 1024px) {
.guide {
padding-top: var(--space-10);
}
.guide__reference {
grid-template-columns: 180px 140px minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-6);
}
}
@@ -21,6 +21,13 @@
box-shadow: var(--shadow-cover); box-shadow: var(--shadow-cover);
} }
.listing__photos {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
padding-top: var(--space-4);
}
.listing__main { .listing__main {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
@@ -1541,6 +1541,16 @@ textarea.input {
padding-left: 16cqw; padding-left: 16cqw;
} }
/* A photo of a copy, cropped to a cover's proportions. */
.photo {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: var(--radius-sm);
background: var(--tint);
object-fit: cover;
}
/* Listing cards */ /* Listing cards */
.listing-card { .listing-card {
position: relative; position: relative;
@@ -1747,6 +1757,13 @@ textarea.input {
font-weight: 500; font-weight: 500;
} }
.select--block {
width: 100%;
height: var(--control-md);
border-color: var(--rule);
font-size: 14px;
}
.sort { .sort {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -69,6 +69,12 @@
gap: var(--space-7); gap: var(--space-7);
} }
.gate__demo {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.gate__saved { .gate__saved {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -782,3 +782,117 @@ a.rows__row:hover {
grid-template-columns: 100px minmax(0, 2fr) 70px minmax(0, 1fr) 340px 130px; grid-template-columns: 100px minmax(0, 2fr) 70px minmax(0, 1fr) 340px 130px;
} }
} }
/* ==========================================================================
New listing
========================================================================== */
.listing-form,
.listing-form__form,
.listing-form__section,
.listing-form__scope {
display: flex;
flex-direction: column;
}
.listing-form {
gap: var(--space-6);
max-width: calc(880px + 2 * var(--gutter));
}
.listing-form__head {
display: flex;
flex-direction: column;
gap: 6px;
}
.listing-form__form {
gap: var(--space-5);
}
.listing-form__section {
gap: var(--space-5);
}
.listing-form__scope {
gap: var(--space-4);
}
.listing-form__grid {
display: grid;
gap: var(--space-4);
align-items: start;
}
.listing-form__modes {
display: grid;
gap: var(--space-3);
}
.listing-form__mode {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--rule);
border-radius: var(--radius-sm);
cursor: pointer;
}
.listing-form__mode:has(input:checked) {
border-color: var(--ink);
background: var(--tint);
}
.listing-form__mode span {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 14px;
}
.listing-form__checks {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px var(--space-4);
}
.listing-form__files {
padding: var(--space-4);
border: 1px dashed var(--rule);
border-radius: var(--radius-sm);
background: var(--paper);
font-size: 13px;
}
.listing-form__submit {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.sell__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
@media (min-width: 600px) {
.listing-form__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.listing-form__modes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field--wide {
grid-column: span 2;
}
}
@media (min-width: 1024px) {
.listing-form {
padding-top: var(--space-8);
}
}
@@ -0,0 +1,60 @@
// CCN — passkey sign-in. Without it, or without WebAuthn, the sign-in screen offers the demo sign-in alone.
const form = document.querySelector("[data-passkey-form]");
const supported = window.PublicKeyCredential
&& PublicKeyCredential.parseCreationOptionsFromJSON
&& PublicKeyCredential.parseRequestOptionsFromJSON;
if (form && supported) {
const button = form.querySelector("[data-passkey]");
const status = form.querySelector("[data-passkey-status]");
const post = async (path, body) => {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const answer = await response.json();
if (!response.ok) {
throw new Error(answer.error ?? "Something went wrong. Try again.");
}
return answer;
};
button.hidden = false;
button.addEventListener("click", async () => {
button.disabled = true;
status.textContent = "";
try {
const { mode, options } = await post("/sign-in/passkey/options", { email: form.elements.email.value });
let credential;
if (mode === "create") {
// A discoverable passkey, so the next sign-in needs no email.
options.authenticatorSelection = { residentKey: "required", userVerification: "preferred" };
credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
});
} else {
credential = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
}
const { location } = await post("/sign-in/passkey/finish", {
credential: credential.toJSON(),
return: form.elements.return.value,
});
window.location.assign(location);
} catch (error) {
// Dismissing the browser's prompt is not an error worth a message.
status.textContent = error.name === "NotAllowedError" ? "" : error.message;
button.disabled = false;
}
});
}
@@ -21,3 +21,22 @@ if (filters) {
sync(); sync();
desktop.addEventListener("change", sync); desktop.addEventListener("change", sync);
} }
// The new-listing form shows every field without scripts; here, only those that apply to the format and grading picked.
const listingForm = document.querySelector(".listing-form__form");
if (listingForm) {
const apply = () => {
const format = listingForm.querySelector("input[name=format]:checked")?.value;
const grading = listingForm.querySelector("input[name=grading]:checked")?.value;
const single = format === "single-issues";
const scopes = { "single-issues": single, collected: !single, slabbed: single && grading === "slabbed" };
for (const element of listingForm.querySelectorAll("[data-scope]")) {
element.hidden = !scopes[element.dataset.scope];
}
};
listingForm.addEventListener("change", apply);
apply();
}
@@ -69,6 +69,7 @@ func application(
// building both against an empty host. // building both against an empty host.
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin, siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
logLevel: reader.logLevel, logLevel: reader.logLevel,
passkeysOrigin: reader.passkeysOrigin,
probe: Probe(fluent: fluent), probe: Probe(fluent: fluent),
accounts: AccountRepository(fluent: fluent), accounts: AccountRepository(fluent: fluent),
trade: TradeRepository(fluent: fluent), trade: TradeRepository(fluent: fluent),
@@ -183,6 +184,7 @@ private func logger(
/// - securityHeaders: the security headers applied to every response. /// - securityHeaders: the security headers applied to every response.
/// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them. /// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them.
/// - logLevel: the level the request-logging middleware logs at. /// - logLevel: the level the request-logging middleware logs at.
/// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to.
/// - probe: the probe consulted by the `HealthController` readiness route. /// - probe: the probe consulted by the `HealthController` readiness route.
/// - accounts: the repository the `SessionController` finds and creates accounts through. /// - accounts: the repository the `SessionController` finds and creates accounts through.
/// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through. /// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through.
@@ -200,6 +202,7 @@ private func router(
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration, securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
siteOrigin: String?, siteOrigin: String?,
logLevel: Logger.Level, logLevel: Logger.Level,
passkeysOrigin: String,
probe: Probe, probe: Probe,
accounts: AccountRepository, accounts: AccountRepository,
trade: TradeRepository, trade: TradeRepository,
@@ -260,6 +263,19 @@ private func router(
assetVersion: assetVersion, assetVersion: assetVersion,
analytics: analytics analytics: analytics
) )
PasskeyController<AppRequestContext>(
origin: passkeysOrigin,
accounts: accounts,
sessions: sessions
)
ListingFormController<AppRequestContext>(
catalogue: repository,
trade: trade,
sessions: sessions,
photos: PhotoStore(staticFilesPath: staticFilesPath),
assetVersion: assetVersion,
analytics: analytics
)
TradeController<AppRequestContext>( TradeController<AppRequestContext>(
trade: trade, trade: trade,
catalogue: repository, catalogue: repository,
@@ -264,6 +264,14 @@ package extension ConfigReader {
) )
} }
/// The origin passkeys are bound to, read from the `passkeys.origin` key.
var passkeysOrigin: String {
string(
forKey: .Passkeys.origin,
default: .Passkeys.origin
)
}
/// The directory the static files are served from, read from the `path.staticFiles` key. /// The directory the static files are served from, read from the `path.staticFiles` key.
var staticFilesPath: String { var staticFilesPath: String {
string( string(
@@ -53,7 +53,7 @@ struct ListingCard: HTML {
value: "true" value: "true"
) )
) { ) {
Cover(listing.cover) ListingImage(listing)
} }
div { div {
h3(.class("type-card-title")) { h3(.class("type-card-title")) {
@@ -0,0 +1,44 @@
import Elementary
import Persistence
/// What a listing looks like: its first photo, or its placeholder cover while it has none.
struct ListingImage: HTML {
// MARK: Properties
/// The listing shown.
let listing: Listing
/// A modifier class positioning or sizing the image, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a listing's image.
/// - Parameters:
/// - listing: the listing shown.
/// - modifier: a modifier class positioning or sizing the image, or `nil` (the default) for none.
init(
_ listing: Listing,
modifier: String? = nil
) {
self.listing = listing
self.modifier = modifier
}
// MARK: Computed
var body: some HTML {
if let photo = listing.photos.first {
img(
.class(["photo", modifier].compactMap(\.self).joined(separator: " ")),
.src(photo),
.alt("Photo of \(listing.title), front"),
.custom(name: "loading", value: "lazy")
)
} else {
Cover(listing.cover, modifier: modifier)
}
}
}
@@ -0,0 +1,81 @@
/// The grading guide's vocabulary: the scale raw books are graded on, and the defects every listing declares.
///
/// Single-sourced: the public guide prints it, the listing form offers it, and the form's checks apply it.
enum Grading {
// MARK: Constants
/// The conditions a collected edition — ungraded — is sold in, best first.
static let conditions = ["Sealed", "Like new", "Very good", "Good", "Acceptable"]
/// The companies that grade and slab.
static let graders = ["CGC", "CBCS", "PGX"]
/// The highest grade a raw book with a colour-breaking crease can carry.
static let creaseCap = 7.0
/// The page qualities a grader states, best first.
static let pageQualities = ["White", "Off-white to white", "Off-white", "Cream"]
/// The scale, best first: each grade and its code.
static let scale: [(grade: Double, code: String)] = [
(10.0, "GM"), (9.8, "NM/M"), (9.6, "NM+"), (9.4, "NM"), (9.2, "NM-"), (9.0, "VF/NM"),
(8.5, "VF+"), (8.0, "VF"), (7.5, "VF-"), (7.0, "FN/VF"), (6.5, "FN+"), (6.0, "FN"),
(5.0, "VG/FN"), (4.0, "VG"), (3.0, "GD/VG"), (2.0, "GD"), (1.0, "FR"), (0.5, "PR"),
]
/// The grades the guide illustrates: what each allows, and what rules a book out of it.
static let references: [(grade: Double, name: String, allowed: String, excluded: String)] = [
(9.8, "Near mint / mint", "Nearly perfect. Flat, glossy, sharp corners, tight staples. Up to one tiny bindery tear.", "Any spine stress you can see without tilting the book. Any crease."),
(9.4, "Near mint", "A few very small spine ticks, a light corner bend. Full gloss.", "Colour breaks. Writing. Foxing."),
(8.0, "Very fine", "Light wear: a few spine ticks, minor corner blunting, slight gloss loss.", "Colour-breaking creases longer than 1 cm. Tears."),
(6.5, "Fine +", "Moderate wear. A colour-breaking crease or two, some spine roll, small arrival date is fine.", "Missing pieces. Detached cover."),
(4.0, "Very good", "Well read. Multiple creases, rounded corners, possible small tear or staple rust.", "Water damage across the cover. Missing pages."),
(2.0, "Good", "Complete but heavily worn. Tears, writing, cover may be loose at one staple.", "Anything missing — that's Fair or Poor."),
]
}
// MARK: - Enumerations
extension Grading {
/// A box on the defect checklist.
enum Defect: String, CaseIterable, Sendable {
case spineStress = "spine-stress"
case crease
case cornerWear = "corner-wear"
case foxing
case tears
case writing
case detached
case caseScuffs = "case-scuffs"
/// What the box means.
var definition: String {
switch self {
case .spineStress: "Small non-colour-breaking lines along the spine from opening the book."
case .crease: "A fold that has cracked the ink. Caps a raw book at 7.0 or below."
case .cornerWear: "Rounded or frayed corners."
case .foxing: "Brown spots or tide marks; usually humidity."
case .tears: "Any loss of paper. Describe size and location."
case .writing: "Arrival dates, names, distributor stamps."
case .detached: "Cover loose at one or both staples; rusted staples."
case .caseScuffs: "Slabs only: scratches or cracks on the holder, not the book."
}
}
/// The box's name, as listings declare it.
var label: String {
switch self {
case .spineStress: "Spine stress / ticks"
case .crease: "Colour-breaking crease"
case .cornerWear: "Corner wear / blunting"
case .foxing: "Foxing / staining"
case .tears: "Tears or missing pieces"
case .writing: "Writing / stamps"
case .detached: "Detached cover / staples"
case .caseScuffs: "Case scuffs / cracks"
}
}
}
}
@@ -0,0 +1,159 @@
import Elementary
import Foundation
import Infrastructure
/// The public grading guide: the scale raw books are graded on, reference grades, and the defect checklist every listing answers.
struct GradingGuidePage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the grading guide.
/// - Parameters:
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = .init(identifier: "en")
self.viewer = viewer
}
}
// MARK: - Page
extension GradingGuidePage: Page {
// MARK: Constants
/// The path the page is served at.
static let path = String.Route.gradingGuide
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page guide")) {
div(.class("guide__head")) {
p(.class("type-eyebrow")) {
"CCN grading guide · public"
}
h1(.class("type-h1")) {
"How CCN sellers grade raw books"
}
p(.class("type-lead")) {
"Slabbed books carry the grader's number and a cert you can verify. Raw books are graded by the seller against this scale, and every defect on the checklist below must be declared. A declared defect is never grounds for a dispute; an undeclared one always is."
}
}
ol(
.class("guide__scale"),
.custom(name: "aria-label", value: "The grading scale, best first")
) {
ForEach(Grading.scale) { step in
li(.class("guide__grade")) {
strong {
step.grade.gradeLabel
}
span {
step.code
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"Reference grades"
}
ul(.class("guide__references")) {
ForEach(Grading.references) { reference in
li(.class("card guide__reference")) {
div(.class("guide__reference-grade")) {
span(.class("type-stat")) {
reference.grade.gradeLabel
}
span(.class("type-label")) {
reference.name
}
}
// The design leaves the example photos as placeholders; so does the prototype.
div(
.class("guide__photo"),
.custom(name: "aria-hidden", value: "true")
) {
"Photo: \(reference.grade.gradeLabel) example"
}
div {
h3(.class("type-eyebrow guide__allowed")) {
"Allowed"
}
p(.class("type-body-sm")) {
reference.allowed
}
}
div {
h3(.class("type-eyebrow guide__excluded")) {
"Not at this grade"
}
p(.class("type-body-sm")) {
reference.excluded
}
}
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"The defect checklist, defined"
}
p(.class("type-body-sm guide__intro")) {
"These are the eight boxes on every listing form. Tick everything that applies and say where it is; the photos should show it."
}
dl(.class("guide__defects")) {
ForEach(Grading.Defect.allCases) { defect in
div(.class("card guide__defect")) {
dt(.class("type-label")) {
defect.label
}
dd(.class("type-caption")) {
defect.definition
}
}
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.guide]
}
var summary: String? {
"The scale CCN sellers grade raw comics on, with reference grades and the defect checklist every listing declares."
}
var title: String {
"Grading guide — CCN"
}
}
@@ -62,7 +62,21 @@ extension ListingPage: Page {
breadcrumb breadcrumb
div(.class("listing__body")) { div(.class("listing__body")) {
div(.class("listing__media")) { div(.class("listing__media")) {
Cover(listing.cover, modifier: "cover--hero") ListingImage(listing, modifier: "cover--hero")
if listing.photos.count > 1 {
ul(.class("listing__photos")) {
ForEach(Array(listing.photos.dropFirst())) { photo in
li {
img(
.class("photo"),
.src(photo),
.alt("Another photo of \(listing.title)"),
.custom(name: "loading", value: "lazy")
)
}
}
}
}
} }
div(.class("listing__main")) { div(.class("listing__main")) {
div(.class("listing__intro")) { div(.class("listing__intro")) {
@@ -300,6 +314,7 @@ private extension ListingPage {
("Printing", listing.printingDetail), ("Printing", listing.printingDetail),
(listing.grade == nil ? "Condition" : "Grade", grade), (listing.grade == nil ? "Condition" : "Grade", grade),
("Page quality", listing.pageQuality), ("Page quality", listing.pageQuality),
("Declared defects", listing.defects.isEmpty ? nil : listing.defects.joined(separator: "; ")),
("Key notes", listing.keyNotes), ("Key notes", listing.keyNotes),
("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"), ("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"),
].compactMap { term, value in ].compactMap { term, value in
@@ -0,0 +1,445 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The new-listing form: what the copy is, its condition against the grading guide, photos of it, and its price.
///
/// Every field applies to some format or grading; without scripts they all show, labelled by what they are for, and the server checks what
/// applies. With scripts, `js/shared.js` hides the ones that do not.
struct NewListingPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The values the form is filled with.
let form: ListingForm
/// What is wrong with the form, by field.
let problems: [ListingForm.Field: String]
/// The viewer's shop.
let shop: Shop
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the new-listing form.
/// - Parameters:
/// - shop: the viewer's shop.
/// - form: the values the form is filled with; an empty form by default.
/// - problems: what is wrong with the form, by field; nothing by default.
/// - viewer: the signed-in viewer.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
shop: Shop,
form: ListingForm = .init(),
problems: [ListingForm.Field: String] = [:],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.form = form
self.locale = .init(identifier: "en")
self.problems = problems
self.shop = shop
self.viewer = viewer
}
}
// MARK: - Page
extension NewListingPage: Page {
// MARK: Constants
/// The path the page is served at, and the form posted to.
static let path = "\(String.Route.sellerDashboard)/new"
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page listing-form")) {
div(.class("listing-form__head")) {
p(.class("type-eyebrow")) {
"\(shop.name) · seller tools"
}
h1(.class("type-h1")) {
"New listing"
}
}
if !problems.isEmpty {
div(
.class("notice notice--bad"),
.custom(name: "role", value: "alert")
) {
p {
strong {
problems.count == 1 ? "One thing to fix before publishing." : "\(problems.count) things to fix before publishing."
}
form.value(.photos) == nil ? " Photos have to be picked again: a browser never keeps a chosen file." : ""
}
}
}
Elementary.form(
.class("listing-form__form"),
.action(Self.path),
.method(.post),
.custom(name: "enctype", value: "multipart/form-data")
) {
what
condition
photos
price
div(.class("listing-form__submit")) {
a(
.class("button"),
.href(.Route.sellerDashboard)
) {
"Cancel"
}
button(
.class("button button--primary button--lg"),
.type(.submit)
) {
"Publish listing"
}
}
}
}
}
}
@HTMLBuilder
var metadata: some HTML {
meta(
.name("robots"),
.content("noindex")
)
siteMetadata
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.trade]
}
var title: String {
"New listing · \(shop.name) — CCN"
}
}
// MARK: - Sections
private extension NewListingPage {
var what: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"1. What is it"
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Format"
}
div(.class("segmented")) {
ForEach(Format.allCases) { format in
label(.class("segmented__option")) {
input(
.type(.radio),
.name(ListingForm.Field.format.rawValue),
.value(format.rawValue)
)
.attributes(.checked, when: form.has(.format, value: format.rawValue))
span {
format.singular
}
}
}
}
problem(.format)
}
div(.class("listing-form__grid")) {
text(.series, "Series or title", placeholder: "Nightward", span: 2)
text(.issue, "Issue or volume", placeholder: "1", hint: "Required for single issues")
text(.publisher, "Publisher", placeholder: "Meridian Comics", span: 2)
text(.year, "Cover year", placeholder: "2019", numeric: true)
select(.printing, "Printing", options: [("1", "1st printing"), ("2", "2nd printing"), ("3", "3rd printing"), ("0", "Later / unknown")])
select(.edition, "Edition", options: [("", "Not applicable"), ("Direct", "Direct"), ("Newsstand", "Newsstand")], scope: "single-issues")
text(.keyNotes, "Key notes", placeholder: "1st app. of the Nightward", hint: "Leave empty unless the issue is a key")
}
}
}
var condition: some HTML {
section(.class("card listing-form__section")) {
div(.class("section-head")) {
h2(.class("type-panel")) {
"2. Condition"
}
a(
.class("link-ink"),
.href(.Route.gradingGuide)
) {
"CCN grading guide"
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "single-issues")
) {
p(.class("type-eyebrow")) {
"Single issues"
}
fieldset(.class("listing-form__modes")) {
legend(.class("visually-hidden")) {
"Slabbed or raw"
}
mode(.slabbed, "Slabbed (graded)", "CGC, CBCS or PGX. Buyers see the cert number to verify.")
mode(.raw, "Raw (seller-graded)", "You grade it against the guide and declare every defect.")
}
div(.class("listing-form__grid")) {
select(.grade, "Grade", options: [("", "Pick a grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") })
select(.grader, "Grader", options: [("", "—")] + Grading.graders.map { ($0, $0) }, scope: "slabbed")
select(.pageQuality, "Page quality", options: [("", "Not stated")] + Grading.pageQualities.map { ($0, $0) }, scope: "slabbed")
text(.cert, "Cert number", placeholder: "4312087-002", scope: "slabbed")
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "collected")
) {
p(.class("type-eyebrow")) {
"Manga, trade paperbacks and hardcovers"
}
div(.class("listing-form__grid")) {
select(.condition, "Condition", options: [("", "Pick a condition")] + Grading.conditions.map { ($0, $0) })
}
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Defects"
}
p(.class("field__hint")) {
"Tick everything that applies. A buyer can't dispute a defect you declared; an undeclared one is always grounds."
}
div(.class("listing-form__checks")) {
ForEach(Grading.Defect.allCases) { defect in
label(.class("check")) {
input(
.type(.checkbox),
.name(ListingForm.Field.defects.rawValue),
.value(defect.rawValue)
)
.attributes(.checked, when: form.has(.defects, value: defect.rawValue))
defect == .caseScuffs ? "\(defect.label) (slab)" : defect.label
}
}
}
problem(.defects)
}
}
}
var photos: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"3. Photos of this copy"
}
div(.class("field")) {
label(
.class("field__label"),
.for("photos")
) {
"Up to \(PhotoStore.maxCount) photos — front first, then back, spine, and the label for slabs"
}
input(
.class("listing-form__files"),
.id("photos"),
.type(.file),
.name(ListingForm.Field.photos.rawValue),
.custom(name: "accept", value: "image/jpeg,image/png,image/webp"),
.custom(name: "multiple", value: "")
)
p(.class("field__hint")) {
"JPEG, PNG or WebP, up to 3 MB each. No stock images. Without photos the listing shows a placeholder cover."
}
problem(.photos)
}
div(.class("field")) {
label(
.class("field__label"),
.for("notes")
) {
"Seller's notes (provenance, case condition, anything a photo can't show)"
}
textarea(
.class("input"),
.id("notes"),
.name(ListingForm.Field.notes.rawValue),
.custom(name: "rows", value: "3"),
.custom(name: "maxlength", value: "1000")
) {
form.value(.notes) ?? ""
}
problem(.notes)
}
}
}
var price: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"4. Price and shipping"
}
div(.class("listing-form__grid")) {
text(.price, "Asking price (EUR)", placeholder: "240", numeric: true)
select(.offers, "Offers", options: [("1", "Accept offers · auto-decline under 80%"), ("0", "Fixed price, no offers")])
text(.shipping, "Tracked shipping to NL (EUR)", placeholder: "6", numeric: true)
}
p(.class("gate-note")) {
Icon(.info, size: 16)
span {
"Ships from \(shop.location) · \(shop.handlingDays) business days handling. Payment is settled between you and the buyer off-platform; CCN records the sale, the agreed price and shipping, and holds the message history for both sides."
}
}
}
}
}
// MARK: - Fields
private extension NewListingPage {
/// A way of grading a single issue, as a radio card.
func mode(
_ mode: Grading.Mode,
_ title: String,
_ text: String
) -> some HTML {
label(.class("listing-form__mode")) {
input(
.type(.radio),
.name(ListingForm.Field.grading.rawValue),
.value(mode.rawValue)
)
.attributes(.checked, when: form.has(.grading, value: mode.rawValue))
span {
strong {
title
}
span(.class("type-small")) {
text
}
}
}
}
/// What is wrong with a field, tied to it by its identifier; nothing when it is fine.
@HTMLBuilder
func problem(
_ field: ListingForm.Field
) -> some HTML {
if let problem = problems[field] {
p(
.class("field__error"),
.id("\(field.rawValue)-error")
) {
problem
}
}
}
/// A select, showing the form's value. A scoped one applies to one format or grading only.
func select(
_ field: ListingForm.Field,
_ title: String,
options: [(value: String, label: String)],
scope: String? = nil
) -> some HTML {
div(.class("field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
Elementary.select(
.class("select select--block"),
.id(field.rawValue),
.name(field.rawValue)
) {
ForEach(options) { option in
Elementary.option(.value(option.value)) {
option.label
}
.attributes(.selected, when: form.value(field) == option.value)
}
}
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
/// A text field, showing the form's value.
func text(
_ field: ListingForm.Field,
_ title: String,
placeholder: String,
hint: String? = nil,
numeric: Bool = false,
span: Int = 1,
scope: String? = nil
) -> some HTML {
div(.class(span == 2 ? "field field--wide" : "field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
input(
.class("input"),
.id(field.rawValue),
.type(.text),
.name(field.rawValue),
.value(form.value(field) ?? ""),
.placeholder(placeholder)
)
.attributes(.custom(name: "inputmode", value: "numeric"), when: numeric)
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
if let hint, problems[field] == nil {
p(.class("field__hint")) {
hint
}
}
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
}
@@ -86,12 +86,20 @@ extension SellerDashboardPage: Page {
summary summary
} }
} }
div(.class("sell__actions")) {
a( a(
.class("button button--outline"), .class("button button--outline"),
.href(shop.path) .href(shop.path)
) { ) {
"View public shop" "View public shop"
} }
a(
.class("button button--primary"),
.href(NewListingPage.path)
) {
"New listing"
}
}
} }
ul(.class("tiles")) { ul(.class("tiles")) {
tile("\(listings.count)", "Active listings", nil) tile("\(listings.count)", "Active listings", nil)
@@ -5,7 +5,8 @@ import Persistence
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back. /// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
/// ///
/// The prototype stubs the passkey ceremony — see ``SessionController``. /// A real passkey creates an account, or signs in one created with a passkey — see ``PasskeyController``. The demo accounts have none, so
/// the stubbed demo sign-in stays beside it — see ``SessionController``.
struct SignInPage { struct SignInPage {
// MARK: Properties // MARK: Properties
@@ -86,20 +87,29 @@ extension SignInPage: Page {
form( form(
.class("gate__form"), .class("gate__form"),
.action(.Route.signIn), .action(.Route.signIn),
.method(.post) .method(.post),
.custom(name: "data-passkey-form", value: "")
) { ) {
input( input(
.type(.hidden), .type(.hidden),
.name("return"), .name("return"),
.value(returnPath) .value(returnPath)
) )
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
button( button(
.class("button button--primary button--xl button--block"), .class("button button--primary button--xl button--block"),
.type(.submit) .type(.button),
.hidden,
.custom(name: "data-passkey", value: "")
) { ) {
Icon(.key, size: 18) Icon(.key, size: 18)
"Continue with passkey" "Continue with passkey"
} }
p(
.class("field__error"),
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
div(.class("field")) { div(.class("field")) {
label( label(
.class("field__label"), .class("field__label"),
@@ -128,24 +138,21 @@ extension SignInPage: Page {
error error
} }
} }
div(.class("gate__saved")) {
Icon(.key, size: 16)
span {
strong {
AccountRepository.demoEmail
}
span(.class("gate__saved-note")) {
" · saved passkey"
}
}
}
p(.class("field__hint")) { p(.class("field__hint")) {
"Unknown email? We create your account and passkey in the same step — nothing else to fill in." "Unknown email? We create your account and passkey in the same step — nothing else to fill in."
} }
} }
div(.class("notice notice--warn gate__demo")) {
p {
"Prototype: the demo accounts have no passkey. The demo sign-in trusts the email above — or signs in the demo buyer, \(AccountRepository.demoEmail), without one. To sell, use joost@kestrelrow.example."
}
button(
.class("button button--outline button--block"),
.type(.submit)
) {
"Demo sign-in, without a passkey"
}
} }
p(.class("notice notice--warn")) {
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
} }
p(.class("gate__recover")) { p(.class("gate__recover")) {
a(.href(.Route.recover)) { a(.href(.Route.recover)) {
@@ -167,7 +174,7 @@ extension SignInPage: Page {
} }
var scripts: [any Asset] { var scripts: [any Asset] {
[StaticFile.shared] [StaticFile.shared, StaticFile.passkey]
} }
var stylesheets: [any Asset] { var stylesheets: [any Asset] {
@@ -0,0 +1,256 @@
import Foundation
import Persistence
/// The new-listing form as posted: the raw values, kept to fill the form back in, and what is wrong with them.
///
/// The form is the listing's data model. Its checks apply the grading guide: a raw book is graded on the scale, a colour-breaking crease caps
/// it, and case scuffs are a slab's alone.
struct ListingForm: Sendable, Equatable {
// MARK: Properties
/// The text values posted, by field name; a field posted more than once keeps every value.
let values: [String: [String]]
// MARK: Initializers
/// Creates the form from the values posted; an empty form by default, with the most common choices made.
init(
values: [String: [String]] = [
Field.format.rawValue: [Format.singleIssues.rawValue],
Field.grading.rawValue: [Grading.Mode.slabbed.rawValue],
Field.printing.rawValue: ["1"],
Field.offers.rawValue: ["1"],
]
) {
self.values = values
}
/// Creates the form from a multipart body's text parts.
init(
_ multipart: MultipartForm
) {
var values = [String: [String]]()
for field in Field.allCases {
values[field.rawValue] = multipart.values(field.rawValue)
}
self.values = values
}
}
// MARK: - Enumerations
extension ListingForm {
/// A field of the form.
enum Field: String, CaseIterable, Sendable {
case cert
case condition
case defects
case edition
case format
case grade
case grader
case grading
case issue
case keyNotes = "key-notes"
case notes
case offers
case pageQuality = "page-quality"
case photos
case price
case printing
case publisher
case series
case shipping
case year
}
}
extension Grading {
/// How a single issue is graded.
enum Mode: String, Sendable {
/// By the seller, against the guide.
case raw
/// By a grading company, in a slab.
case slabbed
}
}
// MARK: - Extensions
extension ListingForm {
// MARK: Computed
/// The format chosen, or `nil` when none valid was.
var format: Format? {
value(.format).flatMap(Format.init)
}
/// Whether the copy is a slabbed single issue.
var isSlabbed: Bool {
format == .singleIssues && value(.grading) == Grading.Mode.slabbed.rawValue
}
// MARK: Methods
/// Whether a field holds a value.
func has(
_ field: Field,
value: String
) -> Bool {
values[field.rawValue]?.contains(value) == true
}
/// The first value of a field, or `nil` when it was left blank.
func value(
_ field: Field
) -> String? {
values[field.rawValue]?.first
}
/// Checks the form.
/// - Parameters:
/// - photos: the URL paths of the photos stored for the listing.
/// - currentYear: the year cover years are checked against.
/// - Returns: the draft the form describes, or what is wrong, by field.
func validated(
photos: [String],
currentYear: Int = Calendar(identifier: .gregorian).component(.year, from: .init())
) -> Result<ListingDraft, Problems> {
var problems = [Field: String]()
func required(
_ field: Field,
_ name: String,
max: Int = 80
) -> String {
guard let value = value(field) else {
problems[field] = "Enter \(name)."
return ""
}
if value.count > max {
problems[field] = "Keep \(name) under \(max) characters."
}
return value
}
let series = required(.series, "the series or title")
let publisher = required(.publisher, "the publisher")
let year = value(.year).flatMap(Int.init) ?? 0
if !(1_900...currentYear + 1).contains(year) {
problems[.year] = "Enter a cover year between 1900 and \(currentYear + 1)."
}
guard let format else {
problems[.format] = "Pick a format."
return .failure(.init(fields: problems))
}
let defects = (values[Field.defects.rawValue] ?? []).compactMap(Grading.Defect.init)
var grader: String?
var grade: Double?
var condition = ""
if format == .singleIssues {
if value(.issue) == nil {
problems[.issue] = "Enter the issue number."
}
let step = value(.grade)
.flatMap(Double.init)
.flatMap { chosen in Grading.scale.first { $0.grade == chosen } }
grade = step?.grade
condition = step?.code ?? ""
if step == nil {
problems[.grade] = "Pick a grade from the scale."
}
if isSlabbed {
grader = value(.grader).flatMap { Grading.graders.contains($0) ? $0 : nil }
if grader == nil {
problems[.grader] = "Pick the grading company."
}
if value(.cert) == nil {
problems[.cert] = "Enter the cert number on the label, so buyers can verify the grade."
}
} else {
if defects.contains(.caseScuffs) {
problems[.defects] = "Case scuffs are for slabs; this copy is raw."
} else if defects.contains(.crease), let grade, grade > Grading.creaseCap {
problems[.defects] = "A colour-breaking crease caps a raw book at \(Grading.creaseCap.gradeLabel). Lower the grade, or untick the crease."
}
}
} else {
condition = value(.condition).flatMap { Grading.conditions.contains($0) ? $0 : nil } ?? ""
if condition.isEmpty {
problems[.condition] = "Pick the condition."
}
}
let price = value(.price).flatMap(Int.init) ?? 0
let shipping = value(.shipping).flatMap(Int.init) ?? -1
if !(1...100_000).contains(price) {
problems[.price] = "Enter the asking price in whole euros."
}
if !(0...100).contains(shipping) {
problems[.shipping] = "Enter the shipping cost to NL in whole euros; 0 for free."
}
if (value(.notes) ?? "").count > 1_000 {
problems[.notes] = "Keep the notes under 1,000 characters."
}
guard problems.isEmpty else {
return .failure(.init(fields: problems))
}
return .success(.init(
series: series,
issue: value(.issue) ?? "",
publisher: publisher,
year: year,
format: format.rawValue,
printing: value(.printing).flatMap(Int.init).map { (1...3).contains($0) ? $0 : 0 } ?? 0,
edition: format == .singleIssues ? (value(.edition) ?? "") : "",
grader: grader,
grade: grade,
condition: condition,
certNumber: isSlabbed ? value(.cert) : nil,
pageQuality: isSlabbed ? value(.pageQuality).flatMap { Grading.pageQualities.contains($0) ? $0 : nil } : nil,
keyNotes: value(.keyNotes),
defects: defects.map(\.label),
sellerNotes: value(.notes) ?? "",
priceCents: price * 100,
shippingCents: shipping * 100,
acceptsOffers: value(.offers) == "1",
photos: photos
))
}
}
// MARK: - Structs
extension ListingForm {
/// What is wrong with a form, by field.
struct Problems: Error, Equatable, Sendable {
/// The problem with each field that has one.
var fields: [Field: String]
}
}
@@ -0,0 +1,104 @@
import MultipartKit
import NIOCore
/// The parts of a `multipart/form-data` body: what a form with file fields posts.
struct MultipartForm: Sendable {
// MARK: Properties
/// The parts, in the order posted.
let parts: [MultipartPart]
// MARK: Initializers
/// Parses a body.
/// - Parameters:
/// - body: the request's body, collected.
/// - contentType: the request's `Content-Type`, which names the boundary between the parts.
/// - Returns: `nil` when the content type is not multipart, or the body does not parse.
init?(
body: ByteBuffer,
contentType: String?
) {
guard
let contentType,
contentType.lowercased().hasPrefix("multipart/form-data"),
let boundary = contentType
.split(separator: ";")
.map({ $0.trimmingCharacters(in: .whitespaces) })
.first(where: { $0.lowercased().hasPrefix("boundary=") })?
.dropFirst("boundary=".count)
.trimmingCharacters(in: .init(charactersIn: "\""))
else {
return nil
}
let parser = MultipartParser(boundary: boundary)
var parts = [MultipartPart]()
var headers = HTTPHeaders()
var buffer = ByteBuffer()
parser.onHeader = { headers.replaceOrAdd(name: $0, value: $1) }
parser.onBody = { buffer.writeBuffer(&$0) }
parser.onPartComplete = {
parts.append(.init(headers: headers, body: buffer))
headers = [:]
buffer = .init()
}
do {
try parser.execute(body)
} catch {
return nil
}
self.parts = parts
}
}
// MARK: - Extensions
extension MultipartForm {
// MARK: Methods
/// The bodies of the file parts of a name that carry a file: a file field left empty posts a part with no file name.
func files(
_ name: String
) -> [ByteBuffer] {
parts
.filter { $0.name == name && !($0.filename ?? "").isEmpty && $0.body.readableBytes > 0 }
.map(\.body)
}
/// The first text value of a name, trimmed, or `nil` when there is none or it is blank.
func value(
_ name: String
) -> String? {
values(name).first
}
/// Every text value of a name, trimmed, the blank ones dropped.
func values(
_ name: String
) -> [String] {
parts
.filter { $0.name == name && $0.filename == nil }
.map { String(buffer: $0.body).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
private extension MultipartPart {
/// The file name the part was posted with, or `nil` for a text field.
var filename: String? {
headers
.first(name: "Content-Disposition")?
.split(separator: ";")
.map { $0.trimmingCharacters(in: .whitespaces) }
.first { $0.lowercased().hasPrefix("filename=") }
.map { String($0.dropFirst("filename=".count)).trimmingCharacters(in: .init(charactersIn: "\"")) }
}
}
@@ -54,12 +54,14 @@ extension StaticFile {
.fontAnton, .fontAnton,
.fontArchivo, .fontArchivo,
.fontOswald, .fontOswald,
.guide,
.icon, .icon,
.icon192, .icon192,
.icon512, .icon512,
.index, .index,
.listing, .listing,
.notFound, .notFound,
.passkey,
.robots, .robots,
.search, .search,
.shared, .shared,
@@ -121,6 +123,9 @@ extension StaticFile {
/// The `font/oswald-variable.woff2` display face (weights 500–700). /// The `font/oswald-variable.woff2` display face (weights 500–700).
static let fontOswald = Self("oswald-variable", as: .woff2) static let fontOswald = Self("oswald-variable", as: .woff2)
/// The `css/guide.css` stylesheet for the grading guide.
static let guide = Self("guide", as: .css)
/// The `icon.svg` icon. /// The `icon.svg` icon.
static let icon = Self("icon", as: .svg) static let icon = Self("icon", as: .svg)
@@ -139,6 +144,9 @@ extension StaticFile {
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page. /// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
static let notFound = Self("not-found", as: .css, .js) static let notFound = Self("not-found", as: .css, .js)
/// The `js/passkey.js` script running the passkey ceremonies on the sign-in page.
static let passkey = Self("passkey", as: .js)
/// The `robots.txt` crawler directives. /// The `robots.txt` crawler directives.
static let robots = Self("robots", as: .txt) static let robots = Self("robots", as: .txt)
@@ -67,6 +67,10 @@ extension CatalogueController: RouterController {
.Catalogue.shop, .Catalogue.shop,
use: shop use: shop
) )
routes.get(
.Catalogue.gradingGuide,
use: gradingGuide
)
return routes return routes
} }
@@ -84,6 +88,23 @@ private extension CatalogueController {
// MARK: Methods // MARK: Methods
/// Handles a request for the grading guide.
@Sendable
func gradingGuide(
request: Request,
context: Context
) async throws -> HTMLResponse {
let viewer = await sessions?.viewer(for: request)
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
GradingGuidePage(
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
}
/// Handles a request for a listing's detail; an unknown slug is not found. /// Handles a request for a listing's detail; an unknown slug is not found.
@Sendable @Sendable
func listing( func listing(
@@ -172,6 +193,8 @@ private extension CatalogueController {
private extension RouterPath { private extension RouterPath {
/// A namespace for the ``CatalogueController`` route paths. /// A namespace for the ``CatalogueController`` route paths.
enum Catalogue { enum Catalogue {
/// The path of the grading guide.
static let gradingGuide: RouterPath = .init(GradingGuidePage.path)
/// The path of a listing's detail. /// The path of a listing's detail.
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)")) static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
/// The path of the search results. /// The path of the search results.
@@ -0,0 +1,206 @@
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Persistence
/// Serves the new-listing form, for the owner of a shop: the form itself, and its publication.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct ListingFormController<Context: RequestContext> {
// MARK: Constants
/// The largest form accepted, in bytes: every photo at its limit, and room for the fields.
private static var maxBodyBytes: Int { PhotoStore.maxCount * PhotoStore.maxBytes + 1_024 * 1_024 }
// MARK: Properties
private let analytics: Analytics?
private let assetVersion: String?
/// The repository listings are published through.
private let catalogue: CatalogueRepository
/// The store the photos are kept in.
private let photos: PhotoStore
/// The store the signed-in account is read from.
private let sessions: SessionStore
/// The repository the seller's shop is read through.
private let trade: TradeRepository
// MARK: Initializers
/// Creates a listing form controller.
/// - Parameters:
/// - catalogue: the repository listings are published through.
/// - trade: the repository the seller's shop is read through.
/// - sessions: the store the signed-in account is read from.
/// - photos: the store the photos are kept in.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
public init(
catalogue: CatalogueRepository,
trade: TradeRepository,
sessions: SessionStore,
photos: PhotoStore,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.catalogue = catalogue
self.photos = photos
self.sessions = sessions
self.trade = trade
}
}
// MARK: - RouterController
extension ListingFormController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.get(
.init(NewListingPage.path),
use: show
)
routes.post(
.init(NewListingPage.path),
use: publish
)
return routes
}
}
// MARK: - Helpers
private extension ListingFormController {
// MARK: Methods
/// Renders the form — with a `422` when it reports problems.
func page(
shop: Shop,
form: ListingForm = .init(),
problems: [ListingForm.Field: String] = [:],
viewer: Viewer,
request: Request,
context: Context
) -> Response {
HTMLResponse(
status: problems.isEmpty ? .ok : .unprocessableContent,
additionalHeaders: .perViewer
) { [analytics, assetVersion] in
NewListingPage(
shop: shop,
form: form,
problems: problems,
viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles the form posted: checks it, stores its photos, publishes the listing, and leads to it.
///
/// The fields are checked before any photo is stored, so a form sent back leaves nothing behind on disk.
@Sendable
func publish(
request: Request,
context: Context
) async throws -> Response {
guard let (user, shop, viewer) = try await seller(of: request) else {
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .normal)
}
let body = try await request.body.collect(upTo: Self.maxBodyBytes)
guard let multipart = MultipartForm(body: body, contentType: request.headers[.contentType]) else {
throw HTTPError(.badRequest)
}
let form = ListingForm(multipart)
if case .failure(let problems) = form.validated(photos: []) {
return page(shop: shop, form: form, problems: problems.fields, viewer: viewer, request: request, context: context)
}
let paths: [String]
do {
paths = try photos.save(multipart.files(ListingForm.Field.photos.rawValue))
} catch let refusal as PhotoStore.Refusal {
return page(shop: shop, form: form, problems: [.photos: refusal.explanation], viewer: viewer, request: request, context: context)
}
guard
case .success(let draft) = form.validated(photos: paths),
let listing = try await catalogue.publish(draft, by: user)
else {
throw HTTPError(.internalServerError)
}
return .redirect(to: listing.path, type: .normal)
}
/// The signed-in seller a request comes from: the account, its shop, and the viewer the page shows; `nil` signed out.
/// - Throws: a `403` for an account that owns no shop.
func seller(
of request: Request
) async throws -> (User, Shop, Viewer)? {
guard
let user = await sessions.user(for: request),
let viewer = await sessions.viewer(for: request)
else {
return nil
}
guard let shop = try await trade.shop(ownedBy: user) else {
throw HTTPError(.forbidden, message: "Only a shop's owner lists copies.")
}
return (user, shop, viewer)
}
/// Handles a request for the empty form.
@Sendable
func show(
request: Request,
context: Context
) async throws -> Response {
guard let (_, shop, viewer) = try await seller(of: request) else {
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .found)
}
return page(shop: shop, viewer: viewer, request: request, context: context)
}
}
// MARK: - Extensions
extension PhotoStore.Refusal {
// MARK: Computed
/// Why the photo was turned down, as the seller reads it.
var explanation: String {
switch self {
case .tooMany: "Pick up to \(PhotoStore.maxCount) photos."
case .tooLarge: "Each photo can be up to 3 MB."
case .notAnImage: "Photos have to be JPEG, PNG or WebP images."
}
}
}
@@ -0,0 +1,308 @@
import Foundation
import Hummingbird
import Infrastructure
import Persistence
import Utility
import WebAuthn
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
///
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
/// passkey registered earlier — a discoverable one when no email is given. 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.
///
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
public struct PasskeyController<Context: RequestContext> {
// MARK: Properties
/// The repository accounts and their passkeys are read and created through.
private let accounts: AccountRepository
/// The ceremonies under way.
private let ceremonies: PasskeyCeremonies
/// Whether the cookies are limited to HTTPS.
private let isSecure: Bool
/// The origin the sign-in screen is served at, which passkeys are bound to.
private let origin: String
/// The store sessions are started in.
private let sessions: SessionStore
// MARK: Initializers
/// Creates a passkey controller.
/// - Parameters:
/// - origin: the origin the sign-in screen is served at, e.g. `https://ccn.example`. Browsers bind a passkey to it, and an answer
/// signed for any other origin is turned down — so it is configuration, never read off a request.
/// - accounts: the repository accounts and their passkeys are read and created through.
/// - sessions: the store sessions are started in.
/// - ceremonies: the ceremonies under way; a set of its own by default.
public init(
origin: String,
accounts: AccountRepository,
sessions: SessionStore,
ceremonies: PasskeyCeremonies = .init()
) {
self.accounts = accounts
self.ceremonies = ceremonies
self.isSecure = origin.hasPrefix("https://")
self.origin = origin
self.sessions = sessions
}
}
// MARK: - RouterController
extension PasskeyController: RouterController {
// MARK: Properties
public var routes: RouteCollection<Context> {
let routes = RouteCollection(context: Context.self)
routes.post(
.init("\(String.Route.signIn)/passkey/options"),
use: options
)
routes.post(
.init("\(String.Route.signIn)/passkey/finish"),
use: finish
)
return routes
}
}
// MARK: - Helpers
private extension PasskeyController {
// MARK: Computed
/// The WebAuthn relying party the ceremonies are run as. Built per request: the manager is not `Sendable`, and holds no state.
var manager: WebAuthnManager {
.init(configuration: .init(
relyingPartyID: URLComponents(string: origin)?.host ?? "localhost",
relyingPartyName: "CCN",
relyingPartyOrigin: origin
))
}
// MARK: Methods
/// Handles the browser's signed answer: checks it against the ceremony it belongs to, then starts the session.
@Sendable
func finish(
request: Request,
context: Context
) async throws -> Response {
guard
let token = request.cookies[PasskeyCeremonies.cookieName]?.value,
let ceremony = await ceremonies.take(token)
else {
return Self.refusal(.badRequest, "That sign-in ran out. Try again.")
}
let body = try await request.body.collect(upTo: 64 * 1_024)
let user: User
do {
switch ceremony.kind {
case .registration(let email):
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
let credential = try await manager.finishRegistration(
challenge: ceremony.challenge,
credentialCreationData: answer.credential,
confirmCredentialIDNotRegisteredYet: { [accounts] in
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
}
)
guard let joined = try await accounts.join(
email: email,
credentialID: Self.base64URL(credential.id),
publicKey: credential.publicKey,
signCount: credential.signCount
) else {
return Self.refusal(.conflict, "That email already has an account.")
}
user = joined
return try await signedIn(user, returningTo: answer.return)
case .authentication:
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
let credentialID = Self.base64URL(answer.credential.id.asString())
guard let passkey = try await accounts.passkey(credentialID: credentialID) else {
return Self.refusal(.unauthorized, "That passkey isn't registered with CCN.")
}
let verified = try manager.finishAuthentication(
credential: answer.credential,
expectedChallenge: ceremony.challenge,
credentialPublicKey: passkey.publicKey,
credentialCurrentSignCount: passkey.signCount
)
try await accounts.recordUse(ofPasskey: credentialID, signCount: verified.newSignCount)
return try await signedIn(passkey.user, returningTo: answer.return)
}
} catch is DecodingError {
return Self.refusal(.badRequest, "That answer couldn't be read.")
} catch is WebAuthnError {
return Self.refusal(.unauthorized, "That passkey couldn't be verified.")
}
}
/// Handles the start of a ceremony: issues the challenge, and tells the script whether to create a passkey or use one.
@Sendable
func options(
request: Request,
context: Context
) async throws -> Response {
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
.trimmingCharacters(in: .whitespaces) ?? ""
// No email: whichever passkey the browser holds for this site.
guard !typed.isEmpty else {
return try await begin(.authentication, options: manager.beginAuthentication(), mode: "get")
}
guard let email = NormalizeEmail()(typed) else {
return Self.refusal(.unprocessableContent, "That doesn't look like an email address.")
}
guard try await accounts.existingAccount(email: email) != nil else {
let options = manager.beginRegistration(user: .init(
id: Array(UUID().uuidString.utf8),
name: email,
displayName: String(email.prefix { $0 != "@" })
))
return try await begin(.registration(email: email), options: options, challenge: options.challenge, mode: "create")
}
let passkeys = try await accounts.passkeys(email: email)
guard !passkeys.isEmpty else {
return Self.refusal(.conflict, "That account has no passkey. The prototype's demo accounts sign in with the demo sign-in below.")
}
return try await begin(
.authentication,
options: manager.beginAuthentication(allowCredentials: passkeys.compactMap { passkey in
Self.bytes(base64URL: passkey.credentialID).map { .init(type: .publicKey, id: $0) }
}),
mode: "get"
)
}
/// Starts an authentication ceremony and answers with its options.
func begin(
_ kind: PasskeyCeremonies.Ceremony.Kind,
options: PublicKeyCredentialRequestOptions,
mode: String
) async throws -> Response {
try await begin(kind, options: options, challenge: options.challenge, mode: mode)
}
/// Starts a ceremony and answers with its options, as `{"mode": …, "options": …}`, and the ceremony's cookie.
func begin(
_ kind: PasskeyCeremonies.Ceremony.Kind,
options: some Encodable,
challenge: [UInt8],
mode: String
) async throws -> Response {
let token = await ceremonies.start(kind, challenge: challenge)
let options = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
var response = Self.json(.ok, #"{"mode":"\#(mode)","options":\#(options)}"#)
response.setCookie(PasskeyCeremonies.cookie(token, isSecure: isSecure))
return response
}
/// Starts the session of a signed-in account, and answers with where to go next.
func signedIn(
_ user: User,
returningTo path: String?
) async throws -> Response {
let token = await sessions.start(for: user)
let location = String(decoding: try JSONEncoder().encode(String.Route.safeReturn(path)), as: UTF8.self)
var response = Self.json(.ok, #"{"location":\#(location)}"#)
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
return response
}
/// A base64 or base64url string as base64url without padding: the one form credential identifiers are kept and compared in.
static func base64URL(
_ string: String
) -> String {
string
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// The bytes a base64url string encodes, or `nil` when it encodes none.
static func bytes(
base64URL string: String
) -> [UInt8]? {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
return Data(base64Encoded: base64).map(Array.init)
}
/// A JSON response, never stored.
static func json(
_ status: HTTPResponse.Status,
_ body: String
) -> Response {
.init(
status: status,
headers: [
.contentType: "application/json; charset=utf-8",
.cacheControl: "no-store",
],
body: .init(byteBuffer: .init(string: body))
)
}
/// A refusal, as `{"error": …}`, for the script to show.
static func refusal(
_ status: HTTPResponse.Status,
_ message: String
) -> Response {
let message = (try? JSONEncoder().encode(message)).map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
return json(status, #"{"error":\#(message)}"#)
}
}
// MARK: - Structs
/// What the script posts to start a ceremony.
private struct Start: Decodable {
let email: String?
}
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
private struct Answer<Credential: Decodable>: Decodable {
let credential: Credential
let `return`: String?
}
@@ -99,6 +99,11 @@ extension ConfigKey {
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity" public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
} }
/// A namespace for the site configuration keys. /// A namespace for the site configuration keys.
/// A namespace for the passkey configuration keys.
public enum Passkeys {
/// The configuration key for the origin the sign-in screen is served at, which passkeys are bound to.
public static let origin: ConfigKey = "passkeys.origin"
}
public enum Site { public enum Site {
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash). /// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
public static let origin: ConfigKey = "site.origin" public static let origin: ConfigKey = "site.origin"
@@ -44,6 +44,13 @@ extension String {
/// The TLS token requiring TLS. /// The TLS token requiring TLS.
public static let tlsRequire = "require" public static let tlsRequire = "require"
} }
/// A namespace for the passkey default configuration values.
public enum Passkeys {
/// The default origin passkeys are bound to: where the service answers when run locally.
///
/// A deployment sets `passkeys.origin` to its own origin. Browsers only run WebAuthn on HTTPS or on `localhost` — not on `127.0.0.1`.
public static let origin = "http://localhost:8080"
}
/// A namespace for well-known path string constants. /// A namespace for well-known path string constants.
public enum Path { public enum Path {
/// The directory, relative to the working directory, that the website's static files are served from. /// The directory, relative to the working directory, that the website's static files are served from.
@@ -0,0 +1,119 @@
import Foundation
import Hummingbird
/// The passkey ceremonies under way: each a challenge the server issued, waiting for the browser's signed answer.
///
/// A ceremony is kept in memory under a random token in a short-lived cookie, and answers once: taking it ends it, so a challenge is never
/// accepted twice.
public actor PasskeyCeremonies {
// MARK: Constants
/// How long a ceremony waits for its answer: five minutes, as long as the browser's own prompt may.
static let lifetime: TimeInterval = 300
/// The name of the cookie holding a ceremony's token.
static let cookieName = "ccn_ceremony"
// MARK: Properties
/// The clock expiries are judged by.
private let now: @Sendable () -> Date
/// The ceremonies under way, by token.
private var pending: [String: Ceremony] = [:]
// MARK: Initializers
/// Creates an empty set of ceremonies.
/// - Parameter now: the clock expiries are judged by; the system's by default.
public init(
now: @escaping @Sendable () -> Date = { .init() }
) {
self.now = now
}
// MARK: Methods
/// Starts a ceremony, dropping the ones that ran out meanwhile.
/// - Returns: the ceremony's token.
func start(
_ kind: Ceremony.Kind,
challenge: [UInt8]
) -> String {
let token = "\(UUID().uuidString)\(UUID().uuidString)"
.replacingOccurrences(of: "-", with: "")
.lowercased()
pending = pending.filter { $0.value.expiresAt > now() }
pending[token] = .init(
kind: kind,
challenge: challenge,
expiresAt: now().addingTimeInterval(Self.lifetime)
)
return token
}
/// Ends a ceremony, handing it over for its answer to be checked against.
/// - Returns: the ceremony, or `nil` when the token names none still waiting.
func take(
_ token: String
) -> Ceremony? {
guard
let ceremony = pending.removeValue(forKey: token),
ceremony.expiresAt > now()
else {
return nil
}
return ceremony
}
}
// MARK: - Structs
extension PasskeyCeremonies {
/// A challenge issued, and what it was issued for.
struct Ceremony: Sendable, Equatable {
/// What the ceremony does.
let kind: Kind
/// The challenge the browser's answer must sign.
let challenge: [UInt8]
/// When the ceremony stops waiting.
let expiresAt: Date
/// What a ceremony does.
enum Kind: Sendable, Equatable {
/// Signs in with a passkey registered earlier.
case authentication
/// Creates the account of the given email, with its first passkey.
case registration(email: String)
}
}
}
// MARK: - Extensions
extension PasskeyCeremonies {
// MARK: Methods
/// The cookie handing a ceremony's token to the browser; an empty token expires it.
nonisolated static func cookie(
_ token: String,
isSecure: Bool
) -> Cookie {
.init(
name: cookieName,
value: token,
maxAge: token.isEmpty ? 0 : Int(lifetime),
path: String.Route.signIn,
secure: isSecure,
httpOnly: true,
sameSite: .strict
)
}
}
@@ -0,0 +1,120 @@
import Foundation
import NIOCore
/// Where the photos of listed copies are kept: a folder under the static files, which `FileMiddleware` serves.
///
/// Prototype scaffolding. A photo is checked for its type and size and stored as posted — not re-encoded, so it keeps whatever metadata it
/// came with — and the folder lives on the instance's own disk. Both change before real sellers upload.
public struct PhotoStore: Sendable {
// MARK: Constants
/// The largest photo stored, in bytes: 3 MB.
public static let maxBytes = 3 * 1_024 * 1_024
/// The most photos a listing carries: front, back, spine, and the grader's label.
public static let maxCount = 4
/// The folder under the static files the photos are kept in, and the URL path they are served under.
static let folder = "uploads"
// MARK: Properties
/// The folder the static files are served from.
private let staticFilesPath: String
// MARK: Initializers
/// Creates a photo store under the given static files folder.
/// - Parameter staticFilesPath: the folder the static files are served from.
public init(
staticFilesPath: String
) {
self.staticFilesPath = staticFilesPath
}
}
// MARK: - Enumerations
extension PhotoStore {
/// Why a photo was turned down.
enum Refusal: Error, Equatable {
/// More photos than a listing carries.
case tooMany
/// A photo over ``PhotoStore/maxBytes``.
case tooLarge
/// A file that is not a JPEG, PNG, or WebP image.
case notAnImage
}
/// An image type the store keeps, told by the file's leading bytes rather than by its name or declared type.
enum Kind: String {
case jpg
case png
case webp
/// Tells the type of a file from its leading bytes, or `nil` when it is none the store keeps.
init?(
sniffing bytes: [UInt8]
) {
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
self = .jpg
} else if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
self = .png
} else if bytes.count >= 12, bytes.starts(with: Array("RIFF".utf8)), Array(bytes[8..<12]) == Array("WEBP".utf8) {
self = .webp
} else {
return nil
}
}
}
}
// MARK: - Extensions
extension PhotoStore {
// MARK: Methods
/// Checks every photo, then stores them all — or none, when one is turned down.
/// - Parameter photos: the files posted, the front first.
/// - Returns: the URL paths the photos are served at, in the order given.
func save(
_ photos: [ByteBuffer]
) throws -> [String] {
guard photos.count <= Self.maxCount else {
throw Refusal.tooMany
}
let kinds = try photos.map { photo in
guard photo.readableBytes <= Self.maxBytes else {
throw Refusal.tooLarge
}
guard let kind = Kind(sniffing: Array(photo.readableBytesView.prefix(12))) else {
throw Refusal.notAnImage
}
return kind
}
guard !photos.isEmpty else {
return []
}
let folder = URL(fileURLWithPath: staticFilesPath).appendingPathComponent(Self.folder)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
// Named by the store alone: nothing of the posted file name reaches the disk or the URL.
return try zip(photos, kinds).map { photo, kind in
let name = "\(UUID().uuidString.lowercased()).\(kind.rawValue)"
try Data(photo.readableBytesView).write(to: folder.appendingPathComponent(name), options: .atomic)
return "/\(Self.folder)/\(name)"
}
}
}
+90
View File
@@ -0,0 +1,90 @@
"""Drives headless Chrome over the DevTools protocol with a stdlib-only WebSocket client, to run real WebAuthn ceremonies."""
import base64, json, os, socket, struct, sys, time, urllib.request
class CDP:
def __init__(self, url):
host, rest = url[len("ws://"):].split("/", 1)
name, port = host.split(":")
self.sock = socket.create_connection((name, int(port)))
key = base64.b64encode(os.urandom(16)).decode()
self.sock.sendall((f"GET /{rest} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
response = b""
while b"\r\n\r\n" not in response:
response += self.sock.recv(4096)
assert b" 101 " in response.split(b"\r\n")[0], response
self.buffer = response.split(b"\r\n\r\n", 1)[1]
self.next_id = 0
def _read(self, count):
while len(self.buffer) < count:
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("closed")
self.buffer += chunk
data, self.buffer = self.buffer[:count], self.buffer[count:]
return data
def _frame(self):
first, second = self._read(2)
length = second & 0x7F
if length == 126:
length = struct.unpack(">H", self._read(2))[0]
elif length == 127:
length = struct.unpack(">Q", self._read(8))[0]
return first & 0x0F, first & 0x80, self._read(length)
def _message(self):
payload = b""
while True:
opcode, final, data = self._frame()
if opcode in (0x0, 0x1, 0x2):
payload += data
if final:
return json.loads(payload)
def send(self, method, **params):
self.next_id += 1
data = json.dumps({"id": self.next_id, "method": method, "params": params}).encode()
mask = os.urandom(4)
header = bytes([0x81])
if len(data) < 126:
header += bytes([0x80 | len(data)])
elif len(data) < 65536:
header += bytes([0x80 | 126]) + struct.pack(">H", len(data))
else:
header += bytes([0x80 | 127]) + struct.pack(">Q", len(data))
self.sock.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
while True:
message = self._message()
if message.get("id") == self.next_id:
if "error" in message:
raise RuntimeError(f"{method}: {message['error']}")
return message.get("result", {})
def evaluate(self, expression):
result = self.send("Runtime.evaluate", expression=expression, awaitPromise=True, returnByValue=True)
if "exceptionDetails" in result:
raise RuntimeError(result["exceptionDetails"].get("exception", {}).get("description", str(result["exceptionDetails"])))
return result["result"].get("value")
def wait_for(self, expression, seconds=10):
deadline = time.time() + seconds
while time.time() < deadline:
try:
if self.evaluate(expression):
return True
except RuntimeError:
pass
time.sleep(0.2)
return False
def connect(port):
for _ in range(50):
try:
targets = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
page = next(t for t in targets if t["type"] == "page")
return CDP(page["webSocketDebuggerUrl"])
except Exception:
time.sleep(0.2)
sys.exit("Chrome's DevTools endpoint never answered")
@@ -0,0 +1,76 @@
"""Runs real passkey ceremonies against a locally running Website, in headless Chrome with a virtual authenticator.
swift run Website & # serves http://localhost:8080 over the in-memory database
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --remote-debugging-port=9333 \\
--user-data-dir="$(mktemp -d)" about:blank &
python3 Tests/Browser/passkeys.py
Needs a fresh server: it joins accounts that must not exist yet. Stdlib only.
"""
import os, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import connect
BASE = "http://localhost:8080"
chrome = connect(9333)
chrome.send("Page.enable")
chrome.send("Runtime.enable")
chrome.send("WebAuthn.enable")
authenticator = chrome.send("WebAuthn.addVirtualAuthenticator", options={
"protocol": "ctap2", "transport": "internal", "hasResidentKey": True,
"hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True,
})["authenticatorId"]
def open_sign_in(return_path):
chrome.send("Page.navigate", url=f"{BASE}/sign-in?return={return_path}")
assert chrome.wait_for("document.querySelector('[data-passkey]') && !document.querySelector('[data-passkey]').hidden"), \
"the passkey button never appeared"
def press(email):
chrome.evaluate(f"document.querySelector('#email').value = {email!r}; document.querySelector('[data-passkey]').click(); true")
def status():
return chrome.evaluate("document.querySelector('[data-passkey-status]')?.textContent ?? ''")
def credentials():
return chrome.send("WebAuthn.getCredentials", authenticatorId=authenticator)["credentials"]
results = []
def check(name, ok, detail=""):
results.append(ok)
print(f"{'PASS' if ok else 'FAIL'} {name}{' · ' + str(detail) if detail else ''}")
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
open_sign_in("/search")
press("sam.okafor@example.com")
landed = chrome.wait_for("location.pathname === '/search'")
check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname"))
check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
held = credentials()
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
# 2. Signed out, the same passkey signs in with no email at all.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/grading-guide")
press("")
landed = chrome.wait_for("location.pathname === '/grading-guide'")
check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "")
check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"])
# 3. By email, the ceremony names the account's credential.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/")
press("Sam.Okafor@Example.com")
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
# 4. A demo account has no passkey, and none can be added to it.
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
open_sign_in("/")
press("mara.v@example.com")
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status())
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
sys.exit(0 if all(results) else 1)
@@ -175,6 +175,17 @@ struct CataloguePagesTests {
#expect(!html.contains("style=")) #expect(!html.contains("style="))
} }
@Test
func `sign-in page hides the passkey button until its script finds WebAuthn, and keeps the demo sign-in working without it`() {
let html = SignInPage(returnPath: "/").render()
#expect(html.contains(#"type="button" hidden data-passkey"#))
#expect(html.contains("/js/passkey.js"))
#expect(html.contains(#"role="alert" data-passkey-status"#))
#expect(html.contains(#"type="submit">Demo sign-in, without a passkey</button>"#))
#expect(html.contains("joost@kestrelrow.example"))
}
@Test @Test
func `sign-in page ties an error to the email field`() { func `sign-in page ties an error to the email field`() {
let html = SignInPage( let html = SignInPage(
@@ -0,0 +1,118 @@
import Elementary
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"Selling pages",
.tags(.page)
)
struct SellingPagesTests {
// MARK: GradingGuidePage tests
@Test
func `grading guide prints the whole scale and defines every defect`() {
let html = GradingGuidePage().render()
for step in Grading.scale {
#expect(html.contains("<strong>\(step.grade.gradeLabel)</strong><span>\(step.code)</span>"))
}
for defect in Grading.Defect.allCases {
#expect(html.contains(defect.label))
#expect(html.contains(defect.definition))
}
#expect(html.contains("Not at this grade"))
#expect(html.contains("/css/guide.css"))
#expect(!html.contains("noindex"))
#expect(!html.contains("style="))
}
// MARK: NewListingPage tests
@Test
func `new-listing form posts as multipart and offers the guide's vocabulary`() {
let html = NewListingPage(shop: .stub()).render()
#expect(html.contains(#"action="/sell/new" method="post" enctype="multipart/form-data""#))
#expect(html.contains(#"type="file" name="photos" accept="image/jpeg,image/png,image/webp" multiple"#))
#expect(html.contains(#"name="format" value="single-issues" checked"#))
#expect(html.contains(#"name="grading" value="slabbed" checked"#))
#expect(html.contains(#"<option value="9.4">9.4 · NM</option>"#))
#expect(html.contains(#"name="defects" value="crease""#))
#expect(html.contains(#"data-scope="collected""#))
#expect(html.contains(#"href="/grading-guide""#))
#expect(html.contains("Ships from Amsterdam, NL"))
#expect(!html.contains(#"role="alert""#))
#expect(!html.contains("style="))
}
@Test
func `new-listing form gives back what was typed, with each problem tied to its field`() {
let html = NewListingPage(
shop: .stub(),
form: ListingFormTests.form(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"], "notes": ["Light <wear>"]]),
problems: [.defects: "A colour-breaking crease caps a raw book at 7.0.", .price: "Enter the asking price in whole euros."]
).render()
#expect(html.contains("2 things to fix before publishing."))
#expect(html.contains("Photos have to be picked again"))
#expect(html.contains(#"name="series" value="Nightward""#))
#expect(html.contains(#"<option value="9.4" selected>"#))
#expect(html.contains(#"name="grading" value="raw" checked"#))
#expect(html.contains(#"name="defects" value="crease" checked"#))
#expect(html.contains(#"id="price-error">Enter the asking price in whole euros."#))
#expect(html.contains(#"aria-invalid="true" aria-describedby="price-error""#))
#expect(html.contains("Light &lt;wear&gt;"))
}
// MARK: Photo tests
@Test
func `listing shows its first photo in place of the placeholder cover`() {
let bare = ListingCard(.stub()).render()
let shot = ListingCard(.stub(photos: ["/uploads/a.jpg", "/uploads/b.jpg"])).render()
let page = ListingPage(listing: .stub(defects: ["Spine stress / ticks"], photos: ["/uploads/a.jpg", "/uploads/b.jpg"]), related: []).render()
#expect(bare.contains("cover--navy"))
#expect(!bare.contains("<img"))
#expect(shot.contains(#"<img class="photo" src="/uploads/a.jpg" alt="Photo of Nightward #1, front""#))
#expect(!shot.contains("cover--navy"))
#expect(page.contains(#"src="/uploads/b.jpg""#))
#expect(page.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
}
// MARK: MultipartForm tests
@Test
func `multipart form tells text from files, and drops what was left blank`() throws {
let body = [
"--x", #"Content-Disposition: form-data; name="series""#, "", " Nightward ",
"--x", #"Content-Disposition: form-data; name="defects""#, "", "crease",
"--x", #"Content-Disposition: form-data; name="defects""#, "", "foxing",
"--x", #"Content-Disposition: form-data; name="issue""#, "", "",
"--x", #"Content-Disposition: form-data; name="photos"; filename="front.jpg""#, "Content-Type: image/jpeg", "", "JPEGDATA",
"--x", #"Content-Disposition: form-data; name="photos"; filename="""#, "Content-Type: application/octet-stream", "", "",
"--x--", "",
].joined(separator: "\r\n")
let form = try #require(MultipartForm(body: .init(string: body), contentType: #"multipart/form-data; boundary="x""#))
#expect(form.value("series") == "Nightward")
#expect(form.values("defects") == ["crease", "foxing"])
#expect(form.value("issue") == nil)
#expect(form.values("photos").isEmpty)
#expect(form.files("photos").map { String(buffer: $0) } == ["JPEGDATA"])
#expect(ListingForm(form).has(.defects, value: "foxing"))
}
@Test
func `multipart form is nothing without a multipart content type`() {
#expect(MultipartForm(body: .init(string: "a=b"), contentType: "application/x-www-form-urlencoded") == nil)
#expect(MultipartForm(body: .init(), contentType: nil) == nil)
}
}
@@ -0,0 +1,119 @@
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"ListingForm type",
.tags(.type)
)
struct ListingFormTests {
// MARK: Methods tests
@Test
func `describes a slabbed single issue`() throws {
let draft = try Self.form().validated(photos: ["/uploads/a.jpg"], currentYear: 2026).get()
#expect(draft.series == "Nightward")
#expect(draft.issue == "1")
#expect(draft.grader == "CGC")
#expect(draft.grade == 9.8)
#expect(draft.condition == "NM/M")
#expect(draft.certNumber == "4312087-002")
#expect(draft.pageQuality == "White")
#expect(draft.priceCents == 24_000)
#expect(draft.shippingCents == 600)
#expect(draft.acceptsOffers)
#expect(draft.photos == ["/uploads/a.jpg"])
}
@Test
func `describes a raw single issue by the scale's code, dropping what only a slab states`() throws {
let draft = try Self.form(["grading": ["raw"], "grade": ["8.0"], "defects": ["spine-stress", "corner-wear", "bogus"]])
.validated(photos: [], currentYear: 2026)
.get()
#expect(draft.grader == nil)
#expect(draft.certNumber == nil)
#expect(draft.pageQuality == nil)
#expect(draft.condition == "VF")
#expect(draft.defects == ["Spine stress / ticks", "Corner wear / blunting"])
}
@Test
func `describes a collected edition by its condition, ungraded`() throws {
let draft = try Self.form(["format": ["hardcovers"], "issue": [], "condition": ["Sealed"], "offers": ["0"]])
.validated(photos: [], currentYear: 2026)
.get()
#expect(draft.issue.isEmpty)
#expect(draft.grade == nil)
#expect(draft.grader == nil)
#expect(draft.condition == "Sealed")
#expect(draft.edition.isEmpty)
#expect(!draft.acceptsOffers)
}
@Test
func `applies the grading guide to a raw book`() {
#expect(Self.problems(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"]]) == [.defects])
#expect(Self.problems(["grading": ["raw"], "grade": ["7.0"], "defects": ["crease"]]).isEmpty)
#expect(Self.problems(["grading": ["raw"], "defects": ["case-scuffs"]]) == [.defects])
// A slab's crease is the grader's business, and its case can be scuffed.
#expect(Self.problems(["defects": ["crease", "case-scuffs"]]).isEmpty)
}
@Test
func `names every field that is missing or out of range`() {
#expect(Self.problems([
"series": [], "publisher": [], "year": ["1850"], "issue": [], "grade": ["9.7"], "grader": ["ACME"],
"cert": [], "price": ["0"], "shipping": ["-1"],
]) == [.series, .publisher, .year, .issue, .grade, .grader, .cert, .price, .shipping])
#expect(Self.problems(["format": ["vinyl"]]) == [.format])
#expect(Self.problems(["format": ["manga"], "condition": ["Mint"]]) == [.condition])
#expect(Self.problems(["year": ["2028"]]) == [.year])
#expect(Self.problems(["price": ["12.50"]]) == [.price])
#expect(Self.problems(["notes": [String(repeating: "a", count: 1_001)]]) == [.notes])
}
@Test
func `starts out with the common choices made`() {
let form = ListingForm()
#expect(form.format == .singleIssues)
#expect(form.isSlabbed)
#expect(form.has(.offers, value: "1"))
#expect(form.value(.series) == nil)
}
}
// MARK: - Helpers
extension ListingFormTests {
/// A valid form for a slabbed Nightward #1, with the given fields replaced.
static func form(
_ overrides: [String: [String]] = [:]
) -> ListingForm {
.init(values: [
"format": ["single-issues"], "series": ["Nightward"], "issue": ["1"], "publisher": ["Meridian Comics"],
"year": ["2019"], "printing": ["1"], "edition": ["Direct"], "grading": ["slabbed"], "grade": ["9.8"],
"grader": ["CGC"], "page-quality": ["White"], "cert": ["4312087-002"], "price": ["240"],
"shipping": ["6"], "offers": ["1"],
].merging(overrides) { $1 })
}
/// The fields a form with the given overrides has problems with.
static func problems(
_ overrides: [String: [String]]
) -> Set<ListingForm.Field> {
guard case .failure(let problems) = form(overrides).validated(photos: [], currentYear: 2026) else {
return []
}
return Set(problems.fields.keys)
}
}
@@ -98,12 +98,14 @@ private extension StaticFileTests {
(.fontAnton, ["font/anton-400.woff2"]), (.fontAnton, ["font/anton-400.woff2"]),
(.fontArchivo, ["font/archivo-variable.woff2"]), (.fontArchivo, ["font/archivo-variable.woff2"]),
(.fontOswald, ["font/oswald-variable.woff2"]), (.fontOswald, ["font/oswald-variable.woff2"]),
(.guide, ["css/guide.css"]),
(.icon, ["icon.svg"]), (.icon, ["icon.svg"]),
(.icon192, ["icon-192.png"]), (.icon192, ["icon-192.png"]),
(.icon512, ["icon-512.png"]), (.icon512, ["icon-512.png"]),
(.index, ["css/index.css", "js/index.js"]), (.index, ["css/index.css", "js/index.js"]),
(.listing, ["css/listing.css"]), (.listing, ["css/listing.css"]),
(.notFound, ["css/not-found.css", "js/not-found.js"]), (.notFound, ["css/not-found.css", "js/not-found.js"]),
(.passkey, ["js/passkey.js"]),
(.robots, ["robots.txt"]), (.robots, ["robots.txt"]),
(.search, ["css/search.css"]), (.search, ["css/search.css"]),
(.shared, ["css/shared.css", "js/shared.js"]), (.shared, ["css/shared.css", "js/shared.js"]),
@@ -0,0 +1,239 @@
import Foundation
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
@Suite(
"ListingFormController controller",
.tags(.controller)
)
struct ListingFormControllerTests {
// MARK: Constants
private let seller = "joost@kestrelrow.example"
/// The fields of a valid raw Nightward #3.
private let fields = [
("format", "single-issues"), ("series", "Nightward"), ("issue", "3"), ("publisher", "Meridian Comics"),
("year", "2019"), ("printing", "1"), ("grading", "raw"), ("grade", "9.4"), ("defects", "spine-stress"),
("notes", "Two light ticks."), ("price", "30"), ("shipping", "5"), ("offers", "1"),
]
// MARK: Functional tests
@Test
func `keeps the form to a shop's owner`() async throws {
try await withApp { client, _ in
try await client.execute(uri: "/sell/new", method: .get) { response in
#expect(response.status == .found)
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
}
let mara = try await signIn(client)
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: mara]) { response in
#expect(response.status == .forbidden)
}
let joost = try await signIn(client, email: seller)
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: joost]) { response in
#expect(response.status == .ok)
#expect(String(buffer: response.body).contains("Kestrel Row Comics · seller tools"))
}
}
}
@Test
func `publishes a listing with its photos, into the shop and the search`() async throws {
try await withApp { client, folder in
let joost = try await signIn(client, email: seller)
let location = try await post(client, fields, photos: [PhotoStoreTests.jpeg, PhotoStoreTests.png], as: joost) { response in
#expect(response.status == .seeOther)
return try #require(response.headers[.location])
}
#expect(location == "/listing/nightward-3-raw-nm")
try await client.execute(uri: location, method: .get) { response in
let body = String(buffer: response.body)
let photos = body.matches(of: /\/uploads\/[0-9a-f-]+\.(jpg|png)/).map { String($0.output.0) }
#expect(body.contains("Nightward #3"))
#expect(body.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
#expect(Set(photos).count == 2)
for photo in photos {
#expect(FileManager.default.fileExists(atPath: folder + photo))
}
}
try await client.execute(uri: "/search?q=nightward+3", method: .get) { response in
#expect(String(buffer: response.body).contains(#"href="/listing/nightward-3-raw-nm""#))
}
}
}
@Test
func `gives the form back with a 422, storing no photo, when a field is wrong`() async throws {
try await withApp { client, folder in
let joost = try await signIn(client, email: seller)
let wrong = fields.map { $0.0 == "grade" ? ("grade", "9.4") : $0 } + [("defects", "crease")]
try await post(client, wrong, photos: [PhotoStoreTests.jpeg], as: joost) { response in
let body = String(buffer: response.body)
#expect(response.status == .unprocessableContent)
#expect(body.contains("caps a raw book at 7.0"))
#expect(body.contains(#"name="series" value="Nightward""#))
}
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
@Test
func `turns down a file that is not an image, publishing nothing`() async throws {
try await withApp { client, _ in
let joost = try await signIn(client, email: seller)
try await post(client, fields, photos: [ByteBuffer(string: "<script>alert(1)</script>")], as: joost) { response in
#expect(response.status == .unprocessableContent)
#expect(String(buffer: response.body).contains("Photos have to be JPEG, PNG or WebP images."))
}
try await client.execute(uri: "/listing/nightward-3-raw-nm", method: .get) { response in
#expect(response.status == .notFound)
}
}
}
@Test
func `takes no listing from a signed-out visitor, a buyer, or a body that is not multipart`() async throws {
try await withApp { client, _ in
try await post(client, fields, photos: [], as: nil) { response in
#expect(response.status == .seeOther)
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
}
let mara = try await signIn(client)
try await post(client, fields, photos: [], as: mara) { response in
#expect(response.status == .forbidden)
}
let joost = try await signIn(client, email: seller)
try await client.execute(
uri: "/sell/new",
method: .post,
headers: [.cookie: joost, .contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "series=Nightward")
) { response in
#expect(response.status == .badRequest)
}
}
}
}
// MARK: - Helpers
private extension ListingFormControllerTests {
/// Posts the form as multipart — the fields, then the photos — as an account, or signed out.
func post<T>(
_ client: any TestClientProtocol,
_ fields: [(String, String)],
photos: [ByteBuffer],
as cookie: String?,
_ check: @escaping @Sendable (TestResponse) throws -> T
) async throws -> T {
var body = ByteBuffer()
for (name, value) in fields {
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
}
for (index, photo) in photos.enumerated() {
var photo = photo
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"p\(index)\"\r\nContent-Type: image/jpeg\r\n\r\n")
body.writeBuffer(&photo)
body.writeString("\r\n")
}
body.writeString("--bnd--\r\n")
var headers: HTTPFields = [.contentType: "multipart/form-data; boundary=bnd"]
headers[.cookie] = cookie
return try await client.execute(uri: "/sell/new", method: .post, headers: headers, body: body, testCallback: check)
}
/// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value.
func signIn(
_ client: any TestClientProtocol,
email: String = ""
) async throws -> String {
try await client.execute(
uri: "/sign-in",
method: .post,
headers: [.contentType: "application/x-www-form-urlencoded"],
body: ByteBuffer(string: "email=\(email)&return=/")
) { response in
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
}
}
/// Runs the body against the session, catalogue, and listing form routes, over a seeded in-memory database and a temporary photo folder.
func withApp(
_ body: @escaping @Sendable (any TestClientProtocol, String) async throws -> Void
) async throws {
try await PhotoStoreTests.withFolder { folder in
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
do {
await PrepareDB()(for: fluent)
try await fluent.migrate()
let catalogue = CatalogueRepository(fluent: fluent)
let trade = TradeRepository(fluent: fluent)
let sessions = SessionStore(trade: trade)
let router = Router(context: WebsiteRequestContext.self)
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
router.addRoutes(ListingFormController<WebsiteRequestContext>(
catalogue: catalogue,
trade: trade,
sessions: sessions,
photos: .init(staticFilesPath: folder)
).routes)
try await Application(router: router).test(.router) { client in
try await body(client, folder)
}
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
}
@@ -0,0 +1,182 @@
import Foundation
import Hummingbird
import HummingbirdTesting
import Logging
import NIOCore
import Persistence
import Testing
@testable import WebsiteLibrary
/// The ceremonies' signatures need a WebAuthn client: `Tests/Browser/passkeys.py` runs them in Chrome. These cover what does not.
@Suite(
"PasskeyController controller",
.tags(.controller)
)
struct PasskeyControllerTests {
// MARK: Options tests
@Test
func `offers whichever passkey the browser holds when given no email`() async throws {
try await withApp { client in
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
let body = try Self.object(response)
let options = try #require(body["options"] as? [String: Any])
#expect(response.status == .ok)
#expect(response.headers[.cacheControl] == "no-store")
#expect(body["mode"] as? String == "get")
#expect(options["rpId"] as? String == "localhost")
#expect((options["challenge"] as? String)?.isEmpty == false)
#expect(options["allowCredentials"] == nil)
#expect(response.headers[.setCookie]?.hasPrefix("ccn_ceremony=") == true)
}
}
}
@Test
func `registers a passkey for an unknown email, normalized`() async throws {
try await withApp { client in
try await client.execute(
uri: "/sign-in/passkey/options",
method: .post,
headers: Self.json,
body: ByteBuffer(string: #"{"email": " Sam@Example.COM "}"#)
) { response in
let body = try Self.object(response)
let options = try #require(body["options"] as? [String: Any])
let user = try #require(options["user"] as? [String: Any])
let party = try #require(options["rp"] as? [String: Any])
#expect(body["mode"] as? String == "create")
#expect(user["name"] as? String == "sam@example.com")
#expect(user["displayName"] as? String == "sam")
#expect(party["id"] as? String == "localhost")
#expect(party["name"] as? String == "CCN")
}
}
}
@Test
func `issues a fresh challenge every time`() async throws {
try await withApp { client in
var challenges = Set<String>()
for _ in 1...3 {
let challenge = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
try #require((try Self.object(response)["options"] as? [String: Any])?["challenge"] as? String)
}
challenges.insert(challenge)
}
#expect(challenges.count == 3)
}
}
@Test(arguments: zip(
[#"{"email": "nope"}"#, #"{"email": "mara.v@example.com"}"#],
[HTTPResponse.Status.unprocessableContent, .conflict]
))
func `starts no ceremony for what is not an email, or for an account without a passkey`(
body: String,
expects status: HTTPResponse.Status
) async throws {
try await withApp { client in
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: body)) { response in
let error = try Self.object(response)["error"] as? String
#expect(response.status == status)
#expect(response.headers[.setCookie] == nil)
#expect(error?.isEmpty == false)
}
}
}
// MARK: Finish tests
@Test
func `signs nobody in without a ceremony, or on an answer it cannot read — and hears a ceremony out once`() async throws {
try await withApp { client in
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
#expect(response.status == .badRequest)
#expect(response.headers[.setCookie] == nil)
}
let cookie = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
}
var headers = Self.json
headers[.cookie] = cookie
try await client.execute(
uri: "/sign-in/passkey/finish",
method: .post,
headers: headers,
body: ByteBuffer(string: #"{"credential": {"id": "x"}}"#)
) { response in
let error = try Self.object(response)["error"] as? String
#expect(response.status == .badRequest)
#expect(error == "That answer couldn't be read.")
#expect(response.headers[values: .setCookie].allSatisfy { !$0.hasPrefix("ccn_session=") })
}
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: headers, body: ByteBuffer(string: "{}")) { response in
let error = try Self.object(response)["error"] as? String
#expect(error == "That sign-in ran out. Try again.")
}
}
}
}
// MARK: - Helpers
private extension PasskeyControllerTests {
static let json: HTTPFields = [.contentType: "application/json"]
/// The JSON object a response carries.
static func object(
_ response: TestResponse
) throws -> [String: Any] {
try #require(try JSONSerialization.jsonObject(with: Data(buffer: response.body)) as? [String: Any])
}
/// Runs the body against the passkey routes, over a migrated, seeded in-memory database.
func withApp(
_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void
) async throws {
let fluent = try Service(
driver: .inMemory,
logger: Logger(label: "test")
)()
do {
await PrepareDB()(for: fluent)
try await fluent.migrate()
let router = Router(context: WebsiteRequestContext.self)
router.addRoutes(PasskeyController<WebsiteRequestContext>(
origin: "http://localhost:8080",
accounts: .init(fluent: fluent),
sessions: .init()
).routes)
try await Application(router: router).test(.router, body)
} catch {
try? await fluent.shutdown()
throw error
}
try await fluent.shutdown()
}
}
@@ -0,0 +1,87 @@
import Foundation
import Testing
@testable import WebsiteLibrary
@Suite(
"PasskeyCeremonies type",
.tags(.type)
)
struct PasskeyCeremoniesTests {
// MARK: Methods tests
@Test
func `answers a ceremony once`() async {
let ceremonies = PasskeyCeremonies()
let token = await ceremonies.start(.registration(email: "sam@example.com"), challenge: [1, 2, 3])
let first = await ceremonies.take(token)
let second = await ceremonies.take(token)
let unknown = await ceremonies.take("unknown")
#expect(first?.kind == .registration(email: "sam@example.com"))
#expect(first?.challenge == [1, 2, 3])
#expect(second == nil)
#expect(unknown == nil)
}
@Test
func `lets a ceremony run out after five minutes`() async {
let clock = Clock()
let ceremonies = PasskeyCeremonies(now: clock.now)
let early = await ceremonies.start(.authentication, challenge: [1])
let late = await ceremonies.start(.authentication, challenge: [2])
clock.advance(by: 299)
let inTime = await ceremonies.take(early)
clock.advance(by: 1)
let tooLate = await ceremonies.take(late)
#expect(inTime != nil)
#expect(tooLate == nil)
}
@Test
func `hands out unguessable tokens, in a cookie kept to the sign-in path`() async {
let ceremonies = PasskeyCeremonies()
let first = await ceremonies.start(.authentication, challenge: [])
let second = await ceremonies.start(.authentication, challenge: [])
let cookie = PasskeyCeremonies.cookie(first, isSecure: true).description
#expect(first.count == 64)
#expect(first != second)
#expect(cookie.contains("Path=/sign-in"))
#expect(cookie.contains("HttpOnly"))
#expect(cookie.contains("SameSite=Strict"))
#expect(cookie.contains("Secure"))
#expect(cookie.contains("Max-Age=300"))
#expect(PasskeyCeremonies.cookie("", isSecure: false).description.contains("Max-Age=0"))
}
}
// MARK: - Helpers
/// A clock the tests move by hand.
final class Clock: @unchecked Sendable {
private let lock = NSLock()
private var date = Date(timeIntervalSince1970: 1_790_000_000)
/// The clock's reading.
@Sendable
func now() -> Date {
lock.withLock { date }
}
/// Moves the clock forward.
func advance(
by interval: TimeInterval
) {
lock.withLock { date.addTimeInterval(interval) }
}
}
@@ -0,0 +1,114 @@
import Foundation
import NIOCore
import Testing
@testable import WebsiteLibrary
@Suite(
"PhotoStore type",
.tags(.type)
)
struct PhotoStoreTests {
// MARK: Constants
static let jpeg = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF, 0xE0] + Array(repeating: UInt8(0), count: 32))
static let png = ByteBuffer(bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + Array(repeating: UInt8(0), count: 32))
static let webp = ByteBuffer(bytes: Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBPVP8 ".utf8))
// MARK: Methods tests
@Test
func `stores photos under names of its own, by the type their bytes tell`() throws {
try Self.withFolder { folder in
let paths = try PhotoStore(staticFilesPath: folder).save([Self.jpeg, Self.png, Self.webp])
#expect(paths.map { $0.split(separator: ".").last } == ["jpg", "png", "webp"])
#expect(paths.allSatisfy { $0.hasPrefix("/uploads/") })
#expect(Set(paths).count == 3)
for path in paths {
#expect(FileManager.default.fileExists(atPath: folder + path))
}
}
}
@Test(arguments: [
ByteBuffer(string: "<script>alert(1)</script>"),
ByteBuffer(string: "GIF89a"),
ByteBuffer(bytes: [0xFF, 0xD8]),
ByteBuffer(string: "RIFF0000WAVE"),
])
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
file: ByteBuffer
) throws {
try Self.withFolder { folder in
#expect(throws: PhotoStore.Refusal.notAnImage) {
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
}
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
@Test
func `turns down too many photos, and one too large`() throws {
try Self.withFolder { folder in
let store = PhotoStore(staticFilesPath: folder)
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
#expect(throws: PhotoStore.Refusal.tooMany) {
try store.save(Array(repeating: Self.jpeg, count: PhotoStore.maxCount + 1))
}
#expect(throws: PhotoStore.Refusal.tooLarge) {
try store.save([large])
}
}
}
@Test
func `stores nothing, and makes no folder, without photos`() throws {
try Self.withFolder { folder in
let paths = try PhotoStore(staticFilesPath: folder).save([])
#expect(paths.isEmpty)
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
}
}
}
// MARK: - Helpers
extension PhotoStoreTests {
/// Runs the body against an empty temporary folder, removed after.
static func withFolder<T>(
_ body: (String) async throws -> T
) async rethrows -> T {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: folder)
}
return try await body(folder.path)
}
/// Runs the body against an empty temporary folder, removed after.
static func withFolder<T>(
_ body: (String) throws -> T
) rethrows -> T {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: folder)
}
return try body(folder.path)
}
}
@@ -14,7 +14,9 @@ extension Listing {
keyNotes: String? = nil, keyNotes: String? = nil,
priceCents: Int = 24_000, priceCents: Int = 24_000,
acceptsOffers: Bool = true, acceptsOffers: Bool = true,
soldAt: Date? = nil soldAt: Date? = nil,
defects: [String] = [],
photos: [String] = []
) -> Self { ) -> Self {
.init( .init(
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1", slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
@@ -25,7 +27,9 @@ extension Listing {
acceptsOffers: acceptsOffers, acceptsOffers: acceptsOffers,
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"), cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
listedAt: .init(timeIntervalSince1970: 0), listedAt: .init(timeIntervalSince1970: 0),
soldAt: soldAt soldAt: soldAt,
defects: defects,
photos: photos
) )
} }
} }