diff --git a/Services/Website/README.md b/Services/Website/README.md
index 811abdb..2677044 100644
--- a/Services/Website/README.md
+++ b/Services/Website/README.md
@@ -5,6 +5,7 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin
The service:
- Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)).
- Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)).
+- Serves the 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)).
- Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers.
- Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)).
- Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable).
@@ -48,6 +49,7 @@ LogRequestsMiddleware
→ FileMiddleware (serves Resources/Static)
RootController (GET / → landing page, language negotiated; GET / → its other editions)
CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request)
+SessionController (GET /sign-in, POST /sign-in, POST /sign-out)
HealthController (GET /health → liveness, GET /health/ready → readiness)
```
@@ -64,10 +66,19 @@ The screens follow the **CCN marketplace** design canvas:
- **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch.
- **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`).
- **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**: buy, offer, message, save, and follow link to the sign-in path with a `return` parameter; the sign-in screen itself is not built yet.
+- **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 link to their own paths (not built yet), while save and follow render disabled.
- **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's lettering and tone.
- **Scripts**: `js/shared.js` only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it.
+### Sign-in
+> **The passkey ceremony is stubbed.** `POST /sign-in` trusts the email it is given, and signs in the seeded demo buyer when given none. Replace it with WebAuthn before anything real depends on it.
+
+- **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` when the site is served over HTTPS). They end with the process.
+- **One step signs in and joins**: an unknown email creates the account, named after the email's local part.
+- **Return to the intent**: the `return` parameter is followed only when it is a rooted path on this site; anything else returns home.
+- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones.
+- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out.
+
### 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.
diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css
index 173b4b4..a9b5250 100644
--- a/Services/Website/Resources/Static/css/shared.css
+++ b/Services/Website/Resources/Static/css/shared.css
@@ -558,6 +558,12 @@ input[type="radio"] {
font-size: 15px;
}
+.button--xl {
+ height: 56px;
+ gap: 10px;
+ font-size: 16px;
+}
+
.button--block {
width: 100%;
}
@@ -688,6 +694,13 @@ input[type="radio"] {
line-height: 1.2;
}
+.field__error {
+ color: var(--bad);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.45;
+}
+
.field__hint {
color: var(--muted);
font-size: 12px;
@@ -705,6 +718,12 @@ input[type="radio"] {
font-size: 14px;
}
+.input--lg {
+ height: 50px;
+ padding: 0 14px;
+ font-size: 16px;
+}
+
.input--sm {
height: var(--control-sm);
padding: 0 10px;
@@ -1030,6 +1049,20 @@ textarea.input {
color: #ffffff;
}
+/* The sign-out is a form, so it cannot be triggered by a link on another site. */
+.site-header__sign-out {
+ display: flex;
+ margin: 0;
+}
+
+.site-header__sign-out button {
+ min-height: var(--control-md);
+ padding: 0 var(--space-1);
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+
.site-header__account {
display: inline-flex;
align-items: center;
diff --git a/Services/Website/Resources/Static/css/sign-in.css b/Services/Website/Resources/Static/css/sign-in.css
new file mode 100644
index 0000000..8ded02b
--- /dev/null
+++ b/Services/Website/Resources/Static/css/sign-in.css
@@ -0,0 +1,107 @@
+/* CCN — sign-in page. Drawn bare: no site header or footer. */
+
+.gate {
+ display: flex;
+ flex: 1 0 auto;
+ flex-direction: column;
+ width: 100%;
+ max-width: 438px;
+ margin: 0 auto;
+}
+
+.gate__bar {
+ position: relative;
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ height: 56px;
+ padding: 0 var(--space-1);
+}
+
+.gate__close {
+ position: absolute;
+ left: var(--space-1);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--control-md);
+ height: var(--control-md);
+ color: var(--ink);
+}
+
+.gate__wordmark {
+ font-family: var(--font-lettering);
+ font-size: 24px;
+ letter-spacing: 0.04em;
+ line-height: 1;
+}
+
+.gate__main {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-7);
+ padding: var(--space-5) var(--space-6) var(--space-6);
+}
+
+.gate__intro {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.gate__intro .type-eyebrow {
+ letter-spacing: 0.1em;
+}
+
+/* The heading stays at its phone size: the screen is a phone-width column at every breakpoint. */
+.gate__intro .type-h1 {
+ font-size: 36px;
+}
+
+.gate__lead {
+ color: var(--ink-soft);
+}
+
+.gate__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-7);
+}
+
+.gate__saved {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ height: var(--control-lg);
+ padding: 0 var(--space-3);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ box-shadow: var(--shadow-sheet);
+ font-size: 14px;
+}
+
+.gate__saved strong {
+ font-weight: 600;
+}
+
+.gate__saved-note {
+ color: var(--muted);
+}
+
+.gate__recover {
+ padding-top: var(--space-4);
+ font-size: 13px;
+ text-align: center;
+}
+
+.gate__recover a {
+ color: var(--muted);
+}
+
+@media (min-width: 600px) {
+ .gate {
+ padding-top: var(--space-10);
+ }
+}
diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift
index 9a63787..1d833f9 100644
--- a/Services/Website/Sources/App/Extensions/App+Build.swift
+++ b/Services/Website/Sources/App/Extensions/App+Build.swift
@@ -70,6 +70,7 @@ func application(
siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin,
logLevel: reader.logLevel,
probe: Probe(fluent: fluent),
+ accounts: AccountRepository(fluent: fluent),
repository: repository,
listings: await homeListings(
from: repository,
@@ -182,6 +183,7 @@ private func logger(
/// - 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.
/// - probe: the probe consulted by the `HealthController` readiness route.
+/// - accounts: the repository the `SessionController` finds and creates accounts through.
/// - repository: the repository the `CatalogueController` reads the shops and listings through.
/// - listings: the listings the landing page shows in its rows.
/// - Returns: the configured router.
@@ -197,6 +199,7 @@ private func router(
siteOrigin: String?,
logLevel: Logger.Level,
probe: Probe,
+ accounts: AccountRepository,
repository: CatalogueRepository,
listings: HomeListings
) -> Router {
@@ -206,6 +209,7 @@ private func router(
context: AppRequestContext.self,
options: .autoGenerateHeadEndpoints
)
+ let sessions = SessionStore()
router.addMiddleware {
LogRequestsMiddleware(logLevel)
@@ -235,10 +239,21 @@ private func router(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics,
- listings: listings
+ listings: listings,
+ sessions: sessions
)
CatalogueController(
repository: repository,
+ sessions: sessions,
+ assetVersion: assetVersion,
+ analytics: analytics
+ )
+ SessionController(
+ sessions: sessions,
+ accounts: accounts,
+ catalogue: repository,
+ // Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable.
+ isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"),
assetVersion: assetVersion,
analytics: analytics
)
diff --git a/Services/Website/Sources/Library/Internal/Components/Icon.swift b/Services/Website/Sources/Library/Internal/Components/Icon.swift
index 5039ce8..ddda1b7 100644
--- a/Services/Website/Sources/Library/Internal/Components/Icon.swift
+++ b/Services/Website/Sources/Library/Internal/Components/Icon.swift
@@ -56,6 +56,8 @@ extension Icon {
case bookmark
/// A check mark.
case check
+ /// A cross.
+ case close
/// A page of text.
case document
/// Filter lines.
@@ -85,6 +87,7 @@ extension Icon.Glyph {
case .bell: #""#
case .bookmark: #""#
case .check: #""#
+ case .close: #""#
case .document: #""#
case .filter: #""#
case .key: #""#
@@ -100,7 +103,7 @@ extension Icon.Glyph {
var strokeWidth: String {
switch self {
case .check: "3.5"
- case .filter, .key, .search, .sort: "2.2"
+ case .close, .filter, .key, .search, .sort: "2.2"
default: "2"
}
}
diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift
index 6abc898..b3eb8bc 100644
--- a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift
+++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift
@@ -218,12 +218,25 @@ private extension SiteHeader {
)
) {
span(.class("from-desktop")) {
- viewer.displayName
+ viewer.firstName
}
span(.class("site-header__avatar")) {
viewer.initials
}
}
+ // Not in the design, whose sign-out lives on the account screen the prototype leaves out.
+ form(
+ .class("site-header__sign-out"),
+ .action(.Route.signOut),
+ .method(.post)
+ ) {
+ button(
+ .class("site-header__text-link"),
+ .type(.submit)
+ ) {
+ "Sign out"
+ }
+ }
}
/// An icon link with a count badge, omitted at zero.
diff --git a/Services/Website/Sources/Library/Internal/Components/Unavailable.swift b/Services/Website/Sources/Library/Internal/Components/Unavailable.swift
new file mode 100644
index 0000000..0569394
--- /dev/null
+++ b/Services/Website/Sources/Library/Internal/Components/Unavailable.swift
@@ -0,0 +1,41 @@
+import Elementary
+
+/// An action the prototype shows but does not build: a button that is visibly, and audibly, unavailable.
+struct Unavailable: HTML {
+
+ // MARK: Properties
+
+ /// The action's name.
+ let label: String
+
+ /// A modifier class sizing or styling the button, or `nil` for none.
+ let modifier: String?
+
+ // MARK: Initializers
+
+ /// Creates an unavailable action.
+ /// - Parameters:
+ /// - label: the action's name.
+ /// - modifier: a modifier class sizing or styling the button, or `nil` (the default) for none.
+ init(
+ _ label: String,
+ modifier: String? = nil
+ ) {
+ self.label = label
+ self.modifier = modifier
+ }
+
+ // MARK: Computed
+
+ var body: some HTML {
+ button(
+ .class(["button", modifier].compactMap(\.self).joined(separator: " ")),
+ .type(.button),
+ .disabled,
+ .title("Not part of the prototype")
+ ) {
+ label
+ }
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift b/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift
new file mode 100644
index 0000000..135667a
--- /dev/null
+++ b/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift
@@ -0,0 +1,8 @@
+import HTTPTypes
+
+extension HTTPFields {
+ /// The headers of a page rendered for its viewer: kept out of shared caches, and revalidated before reuse.
+ static let perViewer: Self = [
+ .cacheControl: "private, no-cache"
+ ]
+}
diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift
index fa00708..1b158a5 100644
--- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift
+++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift
@@ -33,11 +33,34 @@ extension String {
static let series = "/series"
/// The passkey sign-in screen.
static let signIn = "/sign-in"
+ /// The path a sign-out is posted to.
+ static let signOut = "/sign-out"
/// The terms of service.
static let terms = "/terms"
/// The want list.
static let wants = "/wants"
+ /// The path buying a listing outright starts at.
+ /// - Parameter slug: the listing's URL slug.
+ static func buy(
+ _ slug: String
+ ) -> String {
+ "\(listing(slug))/buy"
+ }
+
+ /// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken.
+ /// - Parameters:
+ /// - path: the action's own path.
+ /// - origin: the path of the screen the action is taken on.
+ /// - viewer: the signed-in viewer, or `nil` when signed out.
+ static func gated(
+ _ path: String,
+ from origin: String,
+ viewer: Viewer?
+ ) -> String {
+ viewer == nil ? signIn(returningTo: origin) : path
+ }
+
/// The path of a listing's detail screen.
/// - Parameter slug: the listing's URL slug.
static func listing(
@@ -46,6 +69,30 @@ extension String {
"/listing/\(slug)"
}
+ /// The path of a new message thread about a listing.
+ /// - Parameter slug: the listing's URL slug.
+ static func message(
+ listing slug: String
+ ) -> String {
+ "\(messages)/new?listing=\(slug)"
+ }
+
+ /// The path of a new message thread with a shop.
+ /// - Parameter slug: the shop's URL slug.
+ static func message(
+ shop slug: String
+ ) -> String {
+ "\(messages)/new?shop=\(slug)"
+ }
+
+ /// The path of the offer sheet for a listing.
+ /// - Parameter slug: the listing's URL slug.
+ static func offer(
+ _ slug: String
+ ) -> String {
+ "\(listing(slug))/offer"
+ }
+
/// The path of a shop's public screen.
/// - Parameter slug: the shop's URL slug.
static func shop(
@@ -54,6 +101,27 @@ extension String {
"/shop/\(slug)"
}
+ /// The path to return to after signing in: the one asked for when it stays on this site, else the home screen.
+ ///
+ /// Only a rooted path is followed. Anything else — another origin, a scheme-relative `//host`, a backslash a browser reads as a slash —
+ /// would turn the sign-in into an open redirect.
+ /// - Parameter path: the path asked for, as the `return` parameter carried it.
+ static func safeReturn(
+ _ path: String?
+ ) -> String {
+ guard
+ let path,
+ path.hasPrefix("/"),
+ !path.hasPrefix("//"),
+ !path.contains("\\"),
+ !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control })
+ else {
+ return home
+ }
+
+ return path
+ }
+
/// The path of the sign-in screen, returning to the given path afterwards.
/// - Parameter path: the path the visitor's gated action started from.
static func signIn(
diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
index fe1d0a5..342e88a 100644
--- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
+++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift
@@ -24,6 +24,9 @@ struct IndexPage {
/// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them.
let siteOrigin: String?
+ /// The signed-in viewer, or `nil` when signed out.
+ let viewer: Viewer?
+
/// Resolves the page's text from the bundled String Catalog for the page's ``locale``.
private let localize: Localize
@@ -36,13 +39,16 @@ struct IndexPage {
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
/// - listings: the listings the page shows in its rows; none by default.
+ /// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
init(
locale: Locale,
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil,
- listings: HomeListings = .init()
+ listings: HomeListings = .init(),
+ viewer: Viewer? = nil
) {
+ self.viewer = viewer
self.analytics = analytics
self.assetVersion = assetVersion
self.listings = listings
@@ -90,7 +96,7 @@ extension IndexPage: Page {
// MARK: Properties
var content: some HTML {
- SiteLayout {
+ SiteLayout(viewer: viewer) {
div(.class("page home")) {
section(.class("hero")) {
div(.class("hero__text")) {
diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift
index 6654d38..61ea2e2 100644
--- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift
+++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift
@@ -18,20 +18,26 @@ struct ListingPage {
/// Other listings from the same shop.
let related: [Listing]
+ /// The signed-in viewer, or `nil` when signed out.
+ let viewer: Viewer?
+
// MARK: Initializers
/// Creates the detail screen of a listing.
/// - Parameters:
/// - listing: the listing shown.
/// - related: other listings from the same shop.
+ /// - 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(
listing: Listing,
related: [Listing],
+ viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
+ self.viewer = viewer
self.analytics = analytics
self.assetVersion = assetVersion
self.listing = listing
@@ -48,7 +54,10 @@ extension ListingPage: Page {
// MARK: Computed
var content: some HTML {
- SiteLayout(active: listing.kind) {
+ SiteLayout(
+ active: listing.kind,
+ viewer: viewer
+ ) {
div(.class("page listing")) {
breadcrumb
div(.class("listing__body")) {
@@ -201,48 +210,54 @@ private extension ListingPage {
listing.acceptsOffers ? " · accepts offers" : ""
}
}
- a(
- .class("button button--sm"),
- .href(signIn)
- ) {
- Icon(.bookmark, size: 16)
- "Save"
+ if viewer == nil {
+ a(
+ .class("button button--sm"),
+ .href(.Route.signIn(returningTo: listing.path))
+ ) {
+ Icon(.bookmark, size: 16)
+ "Save"
+ }
+ } else {
+ Unavailable("Save", modifier: "button--sm")
}
}
div(.class("buy-box__actions")) {
a(
.class("button button--primary button--lg"),
- .href(signIn)
+ .href(gated(.Route.buy(listing.slug)))
) {
"Buy now"
}
if listing.acceptsOffers {
a(
.class("button button--outline button--lg"),
- .href(signIn)
+ .href(gated(.Route.offer(listing.slug)))
) {
"Make an offer"
}
}
a(
.class("button button--bare button--lg"),
- .href(signIn)
+ .href(gated(.Route.message(listing: listing.slug)))
) {
Icon(.message, size: 16)
"Message seller"
}
}
- p(.class("gate-note")) {
- Icon(.lock, size: 16)
- span {
- "Buying, offers, saving and messages need a signed-in account. "
- a(
- .class("gate-note__link"),
- .href(signIn)
- ) {
- "Sign in or join with a passkey"
+ if viewer == nil {
+ p(.class("gate-note")) {
+ Icon(.lock, size: 16)
+ span {
+ "Buying, offers, saving and messages need a signed-in account. "
+ a(
+ .class("gate-note__link"),
+ .href(.Route.signIn(returningTo: listing.path))
+ ) {
+ "Sign in or join with a passkey"
+ }
+ " — browsing stays open to everyone."
}
- " — browsing stays open to everyone."
}
}
}
@@ -302,9 +317,13 @@ private extension ListingPage {
}
}
- /// The sign-in screen, returning to this listing.
- var signIn: String {
- .Route.signIn(returningTo: listing.path)
+ // MARK: Methods
+
+ /// The path a gated action on this listing leads to.
+ func gated(
+ _ path: String
+ ) -> String {
+ .Route.gated(path, from: listing.path, viewer: viewer)
}
}
diff --git a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift
index aa0ffe5..f30a550 100644
--- a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift
+++ b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift
@@ -30,6 +30,9 @@ struct SearchPage {
/// The number of shops the matching listings come from.
let shopCount: Int
+ /// The signed-in viewer, or `nil` when signed out.
+ let viewer: Viewer?
+
// MARK: Initializers
/// Creates the search results screen for a search and the listings matching it.
@@ -37,15 +40,18 @@ struct SearchPage {
/// - parameters: the search the screen answers.
/// - matches: every listing matching the search; the screen shows the page the parameters ask for.
/// - publishers: every publisher a listing can be filtered by.
+ /// - 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(
parameters: SearchParameters,
matches: [Listing],
publishers: [String],
+ viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
+ self.viewer = viewer
let slice = Pagination.slice(
matches,
page: parameters.page,
@@ -82,7 +88,8 @@ extension SearchPage: Page {
var content: some HTML {
SiteLayout(
active: formats.count == 1 ? formats.first : nil,
- query: parameters.value(.query)
+ query: parameters.value(.query),
+ viewer: viewer
) {
div(.class("page search")) {
div(.class("search__head")) {
diff --git a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift
index b42a336..4b8d017 100644
--- a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift
+++ b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift
@@ -27,6 +27,9 @@ struct ShopPage {
/// The shop shown.
let shop: Shop
+ /// The signed-in viewer, or `nil` when signed out.
+ let viewer: Viewer?
+
// MARK: Initializers
/// Creates the public screen of a shop.
@@ -35,6 +38,7 @@ struct ShopPage {
/// - inventory: every listing of the shop, counted per format on the tabs.
/// - matches: the shop's listings matching the parameters; the screen shows the page they ask for.
/// - parameters: the tab, quick filters, and sort order applied.
+ /// - 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(
@@ -42,9 +46,11 @@ struct ShopPage {
inventory: [Listing],
matches: [Listing],
parameters: SearchParameters,
+ viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
+ self.viewer = viewer
let slice = Pagination.slice(
matches,
page: parameters.page,
@@ -78,7 +84,7 @@ extension ShopPage: Page {
// MARK: Computed
var content: some HTML {
- SiteLayout {
+ SiteLayout(viewer: viewer) {
div(.class("page shop")) {
section(.class("shop__head")) {
span(.class("monogram monogram--xl")) {
@@ -116,20 +122,24 @@ extension ShopPage: Page {
div(.class("shop__actions")) {
a(
.class("button button--primary"),
- .href(signIn)
+ .href(.Route.gated(.Route.message(shop: shop.slug), from: shop.path, viewer: viewer))
) {
Icon(.message, size: 16)
"Message shop"
}
- a(
- .class("button button--outline"),
- .href(signIn)
- ) {
- "Follow shop"
- }
- span(.class("type-small shop__gate")) {
- Icon(.key, size: 13)
- "Both need a passkey sign-in"
+ if viewer == nil {
+ a(
+ .class("button button--outline"),
+ .href(.Route.signIn(returningTo: shop.path))
+ ) {
+ "Follow shop"
+ }
+ span(.class("type-small shop__gate")) {
+ Icon(.key, size: 13)
+ "Both need a passkey sign-in"
+ }
+ } else {
+ Unavailable("Follow shop", modifier: "button--outline")
}
}
}
@@ -286,11 +296,6 @@ private extension ShopPage {
// MARK: Computed
- /// The sign-in screen, returning to this shop.
- var signIn: String {
- .Route.signIn(returningTo: shop.path)
- }
-
var stats: [(value: String, label: String)] {
[
("\(counts.values.reduce(0, +))", "listings"),
diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift
new file mode 100644
index 0000000..75f1f40
--- /dev/null
+++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift
@@ -0,0 +1,181 @@
+import Elementary
+import Foundation
+import Infrastructure
+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 prototype stubs the passkey ceremony — see ``SessionController``.
+struct SignInPage {
+
+ // MARK: Properties
+
+ let analytics: Analytics?
+ let assetVersion: String?
+ let locale: Locale
+
+ /// What went wrong with the last attempt, or `nil` when nothing did.
+ let error: String?
+
+ /// What the visitor was about to do, or `nil` when they came here directly.
+ let intent: String?
+
+ /// The path the visitor returns to, signed in or not.
+ let returnPath: String
+
+ // MARK: Initializers
+
+ /// Creates the sign-in screen.
+ /// - Parameters:
+ /// - returnPath: the path the visitor returns to, signed in or not.
+ /// - intent: what the visitor was about to do, or `nil` (the default) when they came here directly.
+ /// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did.
+ /// - 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(
+ returnPath: String,
+ intent: String? = nil,
+ error: String? = nil,
+ assetVersion: String? = nil,
+ analytics: Analytics? = nil
+ ) {
+ self.analytics = analytics
+ self.assetVersion = assetVersion
+ self.error = error
+ self.intent = intent
+ self.locale = .init(identifier: "en")
+ self.returnPath = returnPath
+ }
+
+}
+
+// MARK: - Page
+
+extension SignInPage: Page {
+
+ // MARK: Computed
+
+ var content: some HTML {
+ div(.class("gate")) {
+ header(.class("gate__bar")) {
+ a(
+ .class("gate__close"),
+ .href(returnPath),
+ .custom(name: "aria-label", value: "Close and go back")
+ ) {
+ Icon(.close)
+ }
+ span(.class("gate__wordmark")) {
+ "CCN"
+ }
+ }
+ main(.class("gate__main")) {
+ div(.class("gate__intro")) {
+ if let intent {
+ p(.class("type-eyebrow")) {
+ intent
+ }
+ }
+ h1(.class("type-h1")) {
+ "Sign in or join"
+ }
+ p(.class("gate__lead")) {
+ "No password. Your device confirms it's you."
+ }
+ }
+ form(
+ .class("gate__form"),
+ .action(.Route.signIn),
+ .method(.post)
+ ) {
+ input(
+ .type(.hidden),
+ .name("return"),
+ .value(returnPath)
+ )
+ button(
+ .class("button button--primary button--xl button--block"),
+ .type(.submit)
+ ) {
+ Icon(.key, size: 18)
+ "Continue with passkey"
+ }
+ div(.class("field")) {
+ label(
+ .class("field__label"),
+ .for("email")
+ ) {
+ "New here, or on a new device? Enter your email"
+ }
+ input(
+ .class("input input--lg"),
+ .id("email"),
+ .type(.email),
+ .name("email"),
+ .placeholder("you@example.com"),
+ .custom(name: "autocomplete", value: "username webauthn")
+ )
+ .attributes(
+ .custom(name: "aria-invalid", value: "true"),
+ .custom(name: "aria-describedby", value: "email-error"),
+ when: error != nil
+ )
+ if let error {
+ p(
+ .class("field__error"),
+ .id("email-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")) {
+ "Unknown email? We create your account and passkey in the same step — nothing else to fill in."
+ }
+ }
+ }
+ p(.class("notice notice--warn")) {
+ "Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it."
+ }
+ p(.class("gate__recover")) {
+ a(.href(.Route.recover)) {
+ "Lost access to your passkeys?"
+ }
+ }
+ }
+ }
+ }
+
+ /// Kept out of search results: the screen is a step, not a destination.
+ @HTMLBuilder
+ var metadata: some HTML {
+ meta(
+ .name("robots"),
+ .content("noindex")
+ )
+ siteMetadata
+ }
+
+ var scripts: [any Asset] {
+ [StaticFile.shared]
+ }
+
+ var stylesheets: [any Asset] {
+ [StaticFile.shared, StaticFile.signIn]
+ }
+
+ var title: String {
+ "Sign in or join — CCN"
+ }
+
+}
diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift
index 31b3ef4..70a7cac 100644
--- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift
+++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift
@@ -64,6 +64,7 @@ extension StaticFile {
.search,
.shared,
.shop,
+ .signIn,
.site,
.sitemap,
]
@@ -149,6 +150,9 @@ extension StaticFile {
/// The `css/shop.css` stylesheet for the shop page.
static let shop = Self("shop", as: .css)
+ /// The `css/sign-in.css` stylesheet for the sign-in page.
+ static let signIn = Self("sign-in", as: .css)
+
/// The `site.webmanifest` web application manifest.
static let site = Self("site", as: .webmanifest)
diff --git a/Services/Website/Sources/Library/Internal/Types/Viewer.swift b/Services/Website/Sources/Library/Internal/Types/Viewer.swift
index 04fda7c..5192046 100644
--- a/Services/Website/Sources/Library/Internal/Types/Viewer.swift
+++ b/Services/Website/Sources/Library/Internal/Types/Viewer.swift
@@ -1,3 +1,5 @@
+import Persistence
+
/// The signed-in person a page is rendered for.
struct Viewer: Sendable {
@@ -35,8 +37,26 @@ struct Viewer: Sendable {
extension Viewer {
+ // MARK: Initializers
+
+ /// Creates the viewer an account signs in as.
+ /// - Parameter user: the signed-in account.
+ init(
+ _ user: User
+ ) {
+ self.init(displayName: user.displayName)
+ }
+
// MARK: Computed
+ /// The first word of the display name, shown beside the avatar.
+ var firstName: String {
+ displayName
+ .split(separator: " ")
+ .first
+ .map(String.init) ?? displayName
+ }
+
/// The uppercased initials of the first two words of the display name.
var initials: String {
displayName
diff --git a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift
index 080d07f..cfcfb29 100644
--- a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift
@@ -21,21 +21,27 @@ public struct CatalogueController {
/// The repository the shops and listings are read through.
private let repository: CatalogueRepository
+ /// The store the signed-in viewer is read from, or `nil` to render every page signed out.
+ private let sessions: SessionStore?
+
// MARK: Initializers
/// Creates a catalogue controller.
/// - Parameters:
/// - repository: the repository the shops and listings are read through.
+ /// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out.
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
public init(
repository: CatalogueRepository,
+ sessions: SessionStore? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.repository = repository
+ self.sessions = sessions
}
}
@@ -93,10 +99,13 @@ private extension CatalogueController {
.filter { $0.slug != listing.slug }
.prefix(Self.relatedCount)
- return .init { [analytics, assetVersion] in
+ let viewer = await sessions?.viewer(for: request)
+
+ return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
ListingPage(
listing: listing,
related: Array(related),
+ viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
@@ -113,11 +122,14 @@ private extension CatalogueController {
let matches = try await repository.listings(matching: parameters.filter)
let publishers = try await repository.publishers()
- return .init { [analytics, assetVersion] in
+ let viewer = await sessions?.viewer(for: request)
+
+ return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
SearchPage(
parameters: parameters,
matches: matches,
publishers: publishers,
+ viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
@@ -138,12 +150,15 @@ private extension CatalogueController {
let inventory = try await repository.listings(matching: .init(shop: shop.slug))
let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug))
- return .init { [analytics, assetVersion] in
+ let viewer = await sessions?.viewer(for: request)
+
+ return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
ShopPage(
shop: shop,
inventory: inventory,
matches: matches,
parameters: parameters,
+ viewer: viewer,
assetVersion: assetVersion,
analytics: analytics
)
diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
index 4a78ad0..56241ab 100644
--- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift
+++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift
@@ -1,5 +1,6 @@
import Foundation
import Hummingbird
+import HummingbirdElementary
import Infrastructure
import Localization
@@ -18,6 +19,12 @@ public struct RootController {
// MARK: Properties
+ /// What a page rendered per request is built from, the same as the cached ones.
+ private let page: @Sendable (Locale, Viewer?) -> IndexPage
+
+ /// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
+ private let sessions: SessionStore?
+
/// Negotiates the language the bare route answers in.
private let negotiate: Negotiate
@@ -32,26 +39,35 @@ public struct RootController {
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
/// - listings: the listings the landing page shows in its rows; none by default.
+ /// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to serve everyone the cached page.
public init(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil,
- listings: HomeListings = .init()
+ listings: HomeListings = .init(),
+ sessions: SessionStore? = nil
) {
+ let page: @Sendable (Locale, Viewer?) -> IndexPage = {
+ .init(
+ locale: $0,
+ assetVersion: assetVersion,
+ siteOrigin: siteOrigin,
+ analytics: analytics,
+ listings: listings,
+ viewer: $1
+ )
+ }
+
self.negotiate = .init(bundle: .module)
+ self.page = page
+ self.sessions = sessions
// The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache
// and carry it too.
self.responses = .init(
bundle: .module,
variesOnAcceptLanguage: true
) {
- IndexPage(
- locale: $0,
- assetVersion: assetVersion,
- siteOrigin: siteOrigin,
- analytics: analytics,
- listings: listings
- )
+ page($0, nil)
}
}
@@ -103,10 +119,11 @@ private extension RootController {
func index(
request: Request,
context: Context
- ) -> Response {
- responses.response(
- for: negotiate(for: request),
- request: request
+ ) async -> Response {
+ await respond(
+ in: negotiate(for: request),
+ request: request,
+ context: context
)
}
@@ -118,13 +135,37 @@ private extension RootController {
/// - Returns: the handler answering requests for that edition of the page.
func index(
in language: Language
- ) -> @Sendable (Request, Context) -> Response {
- { request, _ in
- responses.response(
- for: language.identifier,
+ ) -> @Sendable (Request, Context) async -> Response {
+ { request, context in
+ await respond(
+ in: language.identifier,
+ request: request,
+ context: context
+ )
+ }
+ }
+
+ /// Answers with the landing page in the given language: the cached page signed out, and one rendered around the viewer signed in.
+ /// - Parameters:
+ /// - language: the identifier of the language to answer in.
+ /// - request: the incoming request.
+ /// - context: the context the request is resolved against.
+ func respond(
+ in language: String,
+ request: Request,
+ context: Context
+ ) async -> Response {
+ guard let viewer = await sessions?.viewer(for: request) else {
+ return responses.response(
+ for: language,
request: request
)
}
+
+ return HTMLResponse(additionalHeaders: .perViewer) { [page] in
+ page(.init(identifier: language), viewer)
+ }
+ .response(from: request, context: context)
}
}
diff --git a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift
new file mode 100644
index 0000000..69ea216
--- /dev/null
+++ b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift
@@ -0,0 +1,234 @@
+import Hummingbird
+import HummingbirdElementary
+import Infrastructure
+import Persistence
+import Utility
+
+/// Serves the gate: the sign-in screen, the sign-in itself, and the sign-out.
+///
+/// The passkey ceremony is **stubbed**: the prototype trusts whatever email it is given, and signs in the demo account when given none.
+/// Everything around the stub is real — the session, its cookie, and the return to whatever the visitor was about to do.
+///
+/// - 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 SessionController {
+
+ // MARK: Properties
+
+ /// The repository accounts are found and created through.
+ private let accounts: AccountRepository
+
+ /// The analytics tracker the page embeds, or `nil` to omit it.
+ private let analytics: Analytics?
+
+ /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
+ private let assetVersion: String?
+
+ /// The repository the visitor's intent is described from.
+ private let catalogue: CatalogueRepository
+
+ /// Whether the session cookie is limited to HTTPS.
+ private let isSecure: Bool
+
+ /// The store sessions are started and ended in.
+ private let sessions: SessionStore
+
+ // MARK: Initializers
+
+ /// Creates a session controller.
+ /// - Parameters:
+ /// - sessions: the store sessions are started and ended in.
+ /// - accounts: the repository accounts are found and created through.
+ /// - catalogue: the repository the visitor's intent is described from.
+ /// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development.
+ /// - 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(
+ sessions: SessionStore,
+ accounts: AccountRepository,
+ catalogue: CatalogueRepository,
+ isSecure: Bool = false,
+ assetVersion: String? = nil,
+ analytics: Analytics? = nil
+ ) {
+ self.accounts = accounts
+ self.analytics = analytics
+ self.assetVersion = assetVersion
+ self.catalogue = catalogue
+ self.isSecure = isSecure
+ self.sessions = sessions
+ }
+
+}
+
+// MARK: - RouterController
+
+extension SessionController: RouterController {
+
+ // MARK: Properties
+
+ public var routes: RouteCollection {
+ let routes = RouteCollection(context: Context.self)
+
+ routes.get(
+ .Session.signIn,
+ use: show
+ )
+ routes.post(
+ .Session.signIn,
+ use: signIn
+ )
+ routes.post(
+ .Session.signOut,
+ use: signOut
+ )
+
+ return routes
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension SessionController {
+
+ // MARK: Structs
+
+ /// The fields the sign-in form posts.
+ struct SignInForm: Decodable {
+ /// The email typed in, or `nil` when the saved passkey is used.
+ let email: String?
+ /// The path to return to.
+ let `return`: String?
+ }
+
+ // MARK: Methods
+
+ /// What the visitor was about to do, read off the path they return to, or `nil` when it names no listing or shop.
+ func intent(
+ returningTo path: String
+ ) async throws -> String? {
+ let segments = path
+ .prefix { $0 != "?" }
+ .split(separator: "/")
+ .map(String.init)
+
+ guard segments.count >= 2 else {
+ return nil
+ }
+
+ switch segments[0] {
+ case "listing":
+ return try await catalogue
+ .listing(slug: segments[1])
+ .map { "To buy, offer on or ask about \($0.title)" }
+ case "shop":
+ return try await catalogue
+ .shop(slug: segments[1])
+ .map { "To message or follow \($0.name)" }
+ default:
+ return nil
+ }
+ }
+
+ /// Renders the sign-in screen, never stored: it reflects the visitor's intent and their last attempt.
+ func page(
+ returnPath: String,
+ error: String? = nil,
+ status: HTTPResponse.Status = .ok,
+ request: Request,
+ context: Context
+ ) async throws -> Response {
+ let intent = try await intent(returningTo: returnPath)
+
+ return HTMLResponse(
+ status: status,
+ additionalHeaders: [.cacheControl: "no-store"]
+ ) { [analytics, assetVersion] in
+ SignInPage(
+ returnPath: returnPath,
+ intent: intent,
+ error: error,
+ assetVersion: assetVersion,
+ analytics: analytics
+ )
+ }
+ .response(from: request, context: context)
+ }
+
+ /// Handles a request for the sign-in screen; a visitor already signed in is sent straight back.
+ @Sendable
+ func show(
+ request: Request,
+ context: Context
+ ) async throws -> Response {
+ let returnPath = String.Route.safeReturn(request.uri.queryParameters.get("return"))
+
+ guard await sessions.viewer(for: request) == nil else {
+ return .redirect(to: returnPath, type: .found)
+ }
+
+ return try await page(
+ returnPath: returnPath,
+ request: request,
+ context: context
+ )
+ }
+
+ /// Handles a sign-in: starts a session for the account — the demo one when no email is given — and returns to the visitor's intent.
+ @Sendable
+ func signIn(
+ request: Request,
+ context: Context
+ ) async throws -> Response {
+ let form = try await URLEncodedFormDecoder().decode(SignInForm.self, from: request, context: context)
+ let returnPath = String.Route.safeReturn(form.return)
+ let typed = form.email?.trimmingCharacters(in: .whitespaces) ?? ""
+
+ guard let email = typed.isEmpty ? AccountRepository.demoEmail : NormalizeEmail()(typed) else {
+ return try await page(
+ returnPath: returnPath,
+ error: "That doesn't look like an email address.",
+ status: .unprocessableContent,
+ request: request,
+ context: context
+ )
+ }
+
+ let token = await sessions.start(for: try await accounts.account(email: email))
+ var response = Response.redirect(to: returnPath, type: .normal)
+
+ response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
+
+ return response
+ }
+
+ /// Handles a sign-out: ends the session, expires its cookie, and returns home.
+ @Sendable
+ func signOut(
+ request: Request,
+ context: Context
+ ) async throws -> Response {
+ if let token = SessionStore.token(in: request) {
+ await sessions.end(token)
+ }
+
+ var response = Response.redirect(to: .Route.home, type: .normal)
+
+ response.setCookie(SessionStore.cookie("", isSecure: isSecure))
+
+ return response
+ }
+
+}
+
+// MARK: - Constants
+
+private extension RouterPath {
+ /// A namespace for the ``SessionController`` route paths.
+ enum Session {
+ /// The path of the sign-in screen, and of the sign-in posted from it.
+ static let signIn: RouterPath = .init(.Route.signIn)
+ /// The path a sign-out is posted to.
+ static let signOut: RouterPath = .init(.Route.signOut)
+ }
+}
diff --git a/Services/Website/Sources/Library/Public/Types/SessionStore.swift b/Services/Website/Sources/Library/Public/Types/SessionStore.swift
new file mode 100644
index 0000000..8e8bd2e
--- /dev/null
+++ b/Services/Website/Sources/Library/Public/Types/SessionStore.swift
@@ -0,0 +1,100 @@
+import Foundation
+import Hummingbird
+import Persistence
+
+/// The signed-in sessions, kept in memory: a token held in a cookie, naming an account.
+///
+/// Prototype scaffolding. Sessions end with the process — as the in-memory database does — and move to storage shared between instances
+/// together with real passkeys.
+public actor SessionStore {
+
+ // MARK: Properties
+
+ /// The accounts signed in, by session token.
+ private var users: [String: User] = [:]
+
+ // MARK: Initializers
+
+ /// Creates an empty session store.
+ public init() {}
+
+ // MARK: Methods
+
+ /// Ends the session with the given token, if there is one.
+ public func end(
+ _ token: String
+ ) {
+ users[token] = nil
+ }
+
+ /// Starts a session for an account.
+ /// - Parameter user: the account signing in.
+ /// - Returns: the session's token, unguessable and unique.
+ public func start(
+ for user: User
+ ) -> String {
+ let token = "\(UUID().uuidString)\(UUID().uuidString)"
+ .replacingOccurrences(of: "-", with: "")
+ .lowercased()
+
+ users[token] = user
+
+ return token
+ }
+
+ /// The account the session with the given token belongs to, or `nil` when there is no such session.
+ public func user(
+ for token: String
+ ) -> User? {
+ users[token]
+ }
+
+}
+
+// MARK: - Extensions
+
+extension SessionStore {
+
+ // MARK: Constants
+
+ /// The name of the cookie holding the session token.
+ static let cookieName = "ccn_session"
+
+ // MARK: Methods
+
+ /// The cookie handing a session token to the browser: hidden from scripts, and sent along with same-site requests and top-level navigation alone.
+ /// - Parameters:
+ /// - token: the session's token; an empty one expires the cookie.
+ /// - isSecure: whether the cookie is limited to HTTPS.
+ nonisolated static func cookie(
+ _ token: String,
+ isSecure: Bool
+ ) -> Cookie {
+ .init(
+ name: cookieName,
+ value: token,
+ maxAge: token.isEmpty ? 0 : nil,
+ path: "/",
+ secure: isSecure,
+ httpOnly: true,
+ sameSite: .lax
+ )
+ }
+
+ /// The session token the request carries, or `nil` when it carries none.
+ nonisolated static func token(
+ in request: Request
+ ) -> String? {
+ request.cookies[cookieName]?.value
+ }
+
+ /// The signed-in viewer the request comes from, or `nil` when it carries no live session.
+ func viewer(
+ for request: Request
+ ) -> Viewer? {
+ Self.token(in: request)
+ .flatMap(user)
+ .map(Viewer.init)
+ }
+
+}
diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift
index 563baa1..6dc603b 100644
--- a/Services/Website/Tests/App/AppTests.swift
+++ b/Services/Website/Tests/App/AppTests.swift
@@ -97,6 +97,36 @@ struct AppTests {
}
}
+ @Test
+ func `sign-in to start a session the other pages recognize`() async throws {
+ try await app(
+ staticFilesPath: staticFilesPath
+ ).test(.router) { client in
+ let cookie = try await client.execute(
+ uri: "/sign-in",
+ method: .post,
+ headers: [.contentType: "application/x-www-form-urlencoded"],
+ body: ByteBuffer(string: "return=/listing/nightward-1-cgc-9-8")
+ ) { response in
+ #expect(response.status == .seeOther)
+ #expect(response.headers[.location] == "/listing/nightward-1-cgc-9-8")
+
+ return String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
+ }
+
+ try await client.execute(
+ uri: "/listing/nightward-1-cgc-9-8",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(body.contains(#"aria-label="Account of Mara Visser""#))
+ #expect(body.contains(#"href="/listing/nightward-1-cgc-9-8/offer""#))
+ }
+ }
+ }
+
@Test
func `error page to be served for an unknown listing`() async throws {
try await app(
diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift
index 441abf1..b1fef3e 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift
@@ -33,6 +33,8 @@ struct SiteHeaderTests {
#expect(html.contains(#"aria-label="Messages, 3 unread""#))
#expect(html.contains(#"href="/sell""#))
#expect(html.contains(">MV<"))
+ #expect(html.contains(">Mara<"))
+ #expect(html.contains(#"action="/sign-out" method="post""#))
#expect(!html.contains(#"href="/sign-in""#))
}
diff --git a/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift
index fdb8daf..99c3532 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift
@@ -74,6 +74,30 @@ struct ListingDisplayTests {
#expect(cents.euros == price)
}
+ @Test(arguments: zip(
+ ["/listing/a?x=1", "/", "//evil.example", "https://evil.example", "/\\evil.example", "listing/a", "", "/a\nb"],
+ ["/listing/a?x=1", "/", "/", "/", "/", "/", "/", "/"]
+ ))
+ func `follows a return path only within the site`(
+ _ path: String,
+ expects safe: String
+ ) {
+ #expect(String.Route.safeReturn(path) == safe)
+ }
+
+ @Test
+ func `returns home without a return path`() {
+ #expect(String.Route.safeReturn(nil) == "/")
+ }
+
+ @Test
+ func `leads a gated action to itself signed in, and to the sign-in signed out`() {
+ let offer = String.Route.offer("a")
+
+ #expect(String.Route.gated(offer, from: "/listing/a", viewer: .init(displayName: "Mara")) == "/listing/a/offer")
+ #expect(String.Route.gated(offer, from: "/listing/a", viewer: nil) == "/sign-in?return=/listing/a")
+ }
+
@Test
func `returns to the gated action's path after sign-in`() {
#expect(String.Route.signIn(returningTo: "/shop/a?keys=1&page=2") == "/sign-in?return=/shop/a%3Fkeys%3D1%26page%3D2")
diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift
index 37281ca..7d15dc8 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift
@@ -121,8 +121,71 @@ struct CataloguePagesTests {
#expect(html.contains("Condition"))
}
+ @Test
+ func `listing page opens its actions to a signed-in viewer`() {
+ let html = ListingPage(
+ listing: .stub(),
+ related: [],
+ viewer: .init(displayName: "Mara Visser")
+ ).render()
+
+ #expect(html.contains(#"href="/listing/nightward-1/buy""#))
+ #expect(html.contains(#"href="/listing/nightward-1/offer""#))
+ #expect(html.contains(#"href="/messages/new?listing=nightward-1""#))
+ #expect(html.contains(#"aria-label="Account of Mara Visser""#))
+ #expect(!html.contains("gate-note"))
+ #expect(html.contains(#"disabled title="Not part of the prototype">Save"#))
+ }
+
// MARK: ShopPage tests
+ @Test
+ func `shop page opens its actions to a signed-in viewer`() {
+ let html = ShopPage(
+ shop: .stub(),
+ inventory: [],
+ matches: [],
+ parameters: .init(),
+ viewer: .init(displayName: "Mara Visser")
+ ).render()
+
+ #expect(html.contains(#"href="/messages/new?shop=kestrel-row-comics""#))
+ #expect(!html.contains("Both need a passkey sign-in"))
+ #expect(!html.contains("/sign-in?return="))
+ }
+
+ // MARK: SignInPage tests
+
+ @Test
+ func `sign-in page is bare, states the intent, and carries the return path`() {
+ let html = SignInPage(
+ returnPath: "/listing/nightward-1",
+ intent: "To buy Nightward #1"
+ ).render()
+
+ #expect(html.contains("To buy Nightward #1"))
+ #expect(html.contains(#"action="/sign-in" method="post""#))
+ #expect(html.contains(#"name="return" value="/listing/nightward-1""#))
+ #expect(html.contains(#"href="/listing/nightward-1" aria-label="Close and go back""#))
+ #expect(html.contains(#"content="noindex""#))
+ #expect(html.contains("/css/sign-in.css"))
+ #expect(!html.contains("site-header"))
+ #expect(!html.contains("site-footer"))
+ #expect(!html.contains("aria-invalid"))
+ #expect(!html.contains("style="))
+ }
+
+ @Test
+ func `sign-in page ties an error to the email field`() {
+ let html = SignInPage(
+ returnPath: "/",
+ error: "That doesn't look like an email address."
+ ).render()
+
+ #expect(html.contains(#"aria-invalid="true" aria-describedby="email-error""#))
+ #expect(html.contains(#"id="email-error">That doesn't look like an email address.
"#))
+ }
+
@Test
func `shop page counts its tabs from the whole inventory and lists the matches`() {
let inventory = [
diff --git a/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift
index 213db36..d0c17f1 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift
@@ -108,6 +108,7 @@ private extension StaticFileTests {
(.search, ["css/search.css"]),
(.shared, ["css/shared.css", "js/shared.js"]),
(.shop, ["css/shop.css"]),
+ (.signIn, ["css/sign-in.css"]),
(.site, ["site.webmanifest"]),
(.sitemap, ["sitemap.xml"]),
]
diff --git a/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift b/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift
index e978df4..c78ceec 100644
--- a/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift
+++ b/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift
@@ -1,3 +1,4 @@
+import Persistence
import Testing
@testable import WebsiteLibrary
@@ -8,6 +9,17 @@ import Testing
)
struct ViewerTests {
+ // MARK: Initializers tests
+
+ @Test
+ func `signs in as the account's display name`() {
+ let viewer = Viewer(User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"))
+
+ #expect(viewer.displayName == "Mara Visser")
+ #expect(viewer.newWantMatches == 0)
+ #expect(viewer.unreadMessages == 0)
+ }
+
// MARK: Computed tests
@Test(arguments: zip(
@@ -21,4 +33,15 @@ struct ViewerTests {
#expect(Viewer(displayName: displayName).initials == initials)
}
+ @Test(arguments: zip(
+ ["Mara Visser", "Mara", ""],
+ ["Mara", "Mara", ""]
+ ))
+ func `first name`(
+ of displayName: String,
+ expects firstName: String
+ ) {
+ #expect(Viewer(displayName: displayName).firstName == firstName)
+ }
+
}
diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift
new file mode 100644
index 0000000..6056aab
--- /dev/null
+++ b/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift
@@ -0,0 +1,233 @@
+import Hummingbird
+import HummingbirdTesting
+import Logging
+import NIOCore
+import Persistence
+import Testing
+
+@testable import WebsiteLibrary
+
+@Suite(
+ "SessionController controller",
+ .tags(.controller)
+)
+struct SessionControllerTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `serves the sign-in screen, never stored, stating what the visitor was about to do`() async throws {
+ try await withApp { client in
+ try await client.execute(
+ uri: "/sign-in?return=/listing/nightward-1-cgc-9-8",
+ method: .get
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .ok)
+ #expect(response.headers[.cacheControl] == "no-store")
+ #expect(body.contains("To buy, offer on or ask about Nightward #1"))
+ #expect(body.contains(#"name="return" value="/listing/nightward-1-cgc-9-8""#))
+ }
+ }
+ }
+
+ @Test
+ func `signs in the demo account without an email and returns to the intent, signed in`() async throws {
+ try await withApp { client in
+ let cookie = try await signIn(client, body: "return=/shop/kestrel-row-comics") { response in
+ #expect(response.status == .seeOther)
+ #expect(response.headers[.location] == "/shop/kestrel-row-comics")
+ }
+
+ try await client.execute(
+ uri: "/shop/kestrel-row-comics",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.headers[.cacheControl] == "private, no-cache")
+ #expect(body.contains(#"aria-label="Account of Mara Visser""#))
+ #expect(!body.contains("Both need a passkey sign-in"))
+ }
+ }
+ }
+
+ @Test
+ func `joins an unknown email, normalized, in the same step`() async throws {
+ try await withApp { client in
+ let cookie = try await signIn(client, body: "email=+Sam%40Example.COM+&return=/")
+
+ try await client.execute(
+ uri: "/search",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ #expect(String(buffer: response.body).contains(#"aria-label="Account of sam""#))
+ }
+ }
+ }
+
+ @Test
+ func `turns down what is not an email, and starts no session`() async throws {
+ try await withApp { client in
+ try await client.execute(
+ uri: "/sign-in",
+ method: .post,
+ headers: [.contentType: "application/x-www-form-urlencoded"],
+ body: ByteBuffer(string: "email=nope&return=/search")
+ ) { response in
+ let body = String(buffer: response.body)
+
+ #expect(response.status == .unprocessableContent)
+ #expect(response.headers[.setCookie] == nil)
+ #expect(body.contains(#"aria-invalid="true""#))
+ #expect(body.contains(#"name="return" value="/search""#))
+ }
+ }
+ }
+
+ @Test(arguments: ["//evil.example/x", "https://evil.example", "evil"])
+ func `returns home rather than off the site`(
+ returnPath: String
+ ) async throws {
+ try await withApp { client in
+ _ = try await signIn(client, body: "return=\(returnPath)") { response in
+ #expect(response.headers[.location] == "/")
+ }
+ }
+ }
+
+ @Test
+ func `sends a signed-in visitor straight back from the sign-in screen`() async throws {
+ try await withApp { client in
+ let cookie = try await signIn(client, body: "return=/")
+
+ try await client.execute(
+ uri: "/sign-in?return=/search",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ #expect(response.status == .found)
+ #expect(response.headers[.location] == "/search")
+ }
+ }
+ }
+
+ @Test
+ func `signs out: ends the session, expires the cookie, and returns home`() async throws {
+ try await withApp { client in
+ let cookie = try await signIn(client, body: "return=/")
+
+ try await client.execute(
+ uri: "/sign-out",
+ method: .post,
+ headers: [.cookie: cookie]
+ ) { response in
+ #expect(response.status == .seeOther)
+ #expect(response.headers[.location] == "/")
+ #expect(response.headers[.setCookie]?.contains("Max-Age=0") == true)
+ }
+
+ // The token itself is dead, not just forgotten by the browser.
+ try await client.execute(
+ uri: "/search",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ #expect(!String(buffer: response.body).contains("Account of"))
+ }
+ }
+ }
+
+ @Test
+ func `serves the landing page cached signed out, and rendered around the viewer signed in`() async throws {
+ try await withApp { client in
+ try await client.execute(
+ uri: "/",
+ method: .get
+ ) { response in
+ #expect(response.headers[.cacheControl] == "public, no-cache")
+ #expect(response.headers[.eTag] != nil)
+ }
+
+ let cookie = try await signIn(client, body: "return=/")
+
+ try await client.execute(
+ uri: "/",
+ method: .get,
+ headers: [.cookie: cookie]
+ ) { response in
+ #expect(response.headers[.cacheControl] == "private, no-cache")
+ #expect(response.headers[.eTag] == nil)
+ #expect(String(buffer: response.body).contains(#"aria-label="Account of Mara Visser""#))
+ }
+ }
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension SessionControllerTests {
+
+ /// Posts the sign-in form and returns the session cookie it sets, as a `Cookie` header value.
+ func signIn(
+ _ client: any TestClientProtocol,
+ body: String,
+ expecting: @escaping @Sendable (TestResponse) throws -> Void = { _ in }
+ ) async throws -> String {
+ try await client.execute(
+ uri: "/sign-in",
+ method: .post,
+ headers: [.contentType: "application/x-www-form-urlencoded"],
+ body: ByteBuffer(string: body)
+ ) { response in
+ try expecting(response)
+
+ let cookie = try #require(response.headers[.setCookie])
+
+ #expect(cookie.contains("HttpOnly"))
+
+ return String(cookie.prefix { $0 != ";" })
+ }
+ }
+
+ /// Runs the body against the session, catalogue, and root routes sharing one session store, 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 catalogue = CatalogueRepository(fluent: fluent)
+ let router = Router(context: WebsiteRequestContext.self)
+ let sessions = SessionStore()
+
+ router.addRoutes(RootController(sessions: sessions).routes)
+ router.addRoutes(CatalogueController(repository: catalogue, sessions: sessions).routes)
+ router.addRoutes(SessionController(
+ sessions: sessions,
+ accounts: .init(fluent: fluent),
+ catalogue: catalogue
+ ).routes)
+
+ try await Application(router: router).test(.router, body)
+ } catch {
+ try? await fluent.shutdown()
+
+ throw error
+ }
+
+ try await fluent.shutdown()
+ }
+
+}
diff --git a/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift b/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift
new file mode 100644
index 0000000..be31785
--- /dev/null
+++ b/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift
@@ -0,0 +1,96 @@
+import Foundation
+import Hummingbird
+import Persistence
+import Testing
+
+@testable import WebsiteLibrary
+
+@Suite(
+ "SessionStore type",
+ .tags(.type)
+)
+struct SessionStoreTests {
+
+ // MARK: Constants
+
+ private let user = User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser")
+
+ // MARK: Methods tests
+
+ @Test
+ func `starts a session under an unguessable, unique token`() async {
+ let sessions = SessionStore()
+ let first = await sessions.start(for: user)
+ let second = await sessions.start(for: user)
+
+ #expect(first.count == 64)
+ #expect(first.allSatisfy { $0.isHexDigit })
+ let found = await sessions.user(for: first)
+
+ #expect(first != second)
+ #expect(found == user)
+ }
+
+ @Test
+ func `forgets a session once ended`() async {
+ let sessions = SessionStore()
+ let token = await sessions.start(for: user)
+
+ await sessions.end(token)
+
+ let ended = await sessions.user(for: token)
+ let unknown = await sessions.user(for: "unknown")
+
+ #expect(ended == nil)
+ #expect(unknown == nil)
+ }
+
+ @Test
+ func `reads the viewer from the request's session cookie`() async {
+ let sessions = SessionStore()
+ let token = await sessions.start(for: user)
+
+ let signedIn = await sessions.viewer(for: request(cookie: "theme=dark; ccn_session=\(token)"))
+ let forged = await sessions.viewer(for: request(cookie: "ccn_session=forged"))
+ let signedOut = await sessions.viewer(for: request(cookie: nil))
+
+ #expect(signedIn?.displayName == "Mara Visser")
+ #expect(forged == nil)
+ #expect(signedOut == nil)
+ }
+
+ @Test
+ func `hides the cookie from scripts and other sites`() {
+ let cookie = SessionStore.cookie("abc", isSecure: true).description
+
+ #expect(cookie.hasPrefix("ccn_session=abc"))
+ #expect(cookie.contains("HttpOnly"))
+ #expect(cookie.contains("SameSite=Lax"))
+ #expect(cookie.contains("Secure"))
+ #expect(cookie.contains("Path=/"))
+ #expect(!SessionStore.cookie("abc", isSecure: false).description.contains("Secure"))
+ }
+
+ @Test
+ func `expires the cookie when handed no token`() {
+ #expect(SessionStore.cookie("", isSecure: false).description.contains("Max-Age=0"))
+ }
+
+}
+
+// MARK: - Helpers
+
+private extension SessionStoreTests {
+
+ /// A request carrying the given `Cookie` header, or none.
+ func request(
+ cookie: String?
+ ) -> Request {
+ var head = HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/")
+
+ head.headerFields[.cookie] = cookie
+
+ return .init(head: head, body: .init(buffer: .init()))
+ }
+
+}