07 — public-site-archive (Design)
07 — public-site-archive (Design)
Section titled “07 — public-site-archive (Design)”Status: Draft
Date: 2026-05-23
Owner: frontend (Astro)
Depends on: 04 auction-engine (D1 mirror auctions), 06 real-time-hub (HubDO lobby), 02 identity-onboarding (none — public)
Binding refs: master §4.8 domain layout, D17 Astro, D19 i18n, D22 MVP scope; research/astro-cf-monorepo-patterns.md §1, §5, §9.
1. Purpose & scope
Section titled “1. Purpose & scope”Purpose. Render the public face of nowyourlink.com — today’s Spotlight, the permanent /day/{n} archive, click-tracking redirect, OG images, SEO surface, i18n strings, like button — on a single @astrojs/cloudflare Worker, with most pages served from the cache edge.
In scope (this spec owns):
- Astro app at
apps/webdeployed tonowyourlink.com(apex) andnowyourlink.com/app(same worker, different routes — dashboard route handled by 08). - Pages:
/,/day/[n],/go/[auction_id],/archive,/about,/press,/legal/*. - OG image generation at
cdn.nowyourlink.com/og/daily/{date}.png. - Click-tracking redirect
/go/{auction_id}with anonymized click log + UTM rewrite. - SEO:
robots.txt,sitemap.xml, JSON-LD Article + Organization, Open Graph, hreflang. - i18n via Paraglide (EN default, DE secondary).
- Like button (cookie-id, per day) with KV store + D1 aggregate rollup.
- The HTML/CSS slots where 09’s push opt-in widgets mount.
Out of scope (not owned):
- Advertiser dashboard at
nowyourlink.com/app/*(08 owns; same Astro worker, different route tree). - Push prompt, predict-the-price widget — 09 owns the islands; 07 only hosts the markup slots. (The Disqus thread loader and its cookie-consent banner were removed — see spec 09 §4.)
- Auction bid endpoints, conversion pixel ingest (
apps/api). - Authoring/moderating creatives, choosing the winner (03/04/05).
- Cinematic WS island for live bidder count (09 owns the island; 07 hosts the mount point and the data attribute
data-auction-id).
2. Astro app layout
Section titled “2. Astro app layout”apps/web uses @astrojs/cloudflare with output: 'server' (research §1). Routes are SSR by default; static archive pages opt in with export const prerender = true.
apps/web/ astro.config.ts # adapter @astrojs/cloudflare, integrations: paraglide wrangler.jsonc # vars, KV (NYL_SESSION, NYL_LIKES), service bindings (API, WS), R2 (OG_CACHE), D1 (NYL_D1) src/ env.d.ts # references worker-configuration.d.ts (wrangler types) layouts/ Base.astro # <html lang> from locale; OG meta slot; consent banner Day.astro # extends Base; structured-data Article slot components/ SpotlightCard.astro # today's winner card LiveCountdown.astro # WS island (mount only; src in 09) LikeButton.astro # POSTs to /api/like ShareButtons.astro Sparkline.astro # SVG-only, server-rendered BidGrid.astro # emoji grid (server-rendered) DisqusSlot.astro # REMOVED (Disqus dropped — 09 §4); file dormant, not rendered LangSwitch.astro SeoHead.astro # JSON-LD + OG + hreflang pages/ index.astro # / day/[n].astro # /day/1, /day/2, ... (SSR + cache; see §4) go/[auction_id].ts # endpoint, 302 redirect (see §6) archive/index.astro # full list, paginated about.astro press.astro legal/impressum.astro legal/agb.astro legal/datenschutz.astro api/like.ts # POST endpoint (Astro endpoint, KV write) api/og/[date].ts # SSR OG PNG (lazy generation; see §5) paraglide/ messages/ en.json de.jsonSSR vs static.
| Route | Mode | Cache header | Reason |
|---|---|---|---|
/ | SSR | s-maxage=60 | Today’s Spotlight changes daily; activity signals stream via WS island |
/day/[n] | SSR + edge cache | s-maxage=86400, stale-while-revalidate=604800 | Immutable once the day has elapsed |
/go/[auction_id] | SSR (endpoint) | Cache-Control: no-store | Click log must run |
/archive | SSR | s-maxage=300 | List grows daily |
/about, /press, /legal/* | prerender = true | static | Rarely changes |
/api/like | SSR | no-store | Mutating endpoint |
/api/og/[date].ts | SSR | s-maxage=86400, immutable after first build | See §5 |
Astro.locals.runtime.env is fully typed via wrangler types (research §1). No DO bindings on apps/web — all WS traffic goes via service binding to apps/ws (research anti-pitfall §10).
3. Homepage composition
Section titled “3. Homepage composition”pages/index.astro renders, in order:
- Hero / Spotlight card —
SpotlightCard.astroreads from D1 mirror tabledaily_spotlight(date PRIMARY KEY, ad_id, title, description, image_url, target_url, advertiser_company, winning_amount_cents, bidder_count, winning_at)(populated by 05 onsaga.activated;winning_atmirrors spec-04closes_at_actualas ISO-8601 UTC and is the timestamp the archive header in §4 displays). Falls back to the house-ad row (ad_id LIKE 'house:%') when no winner. Thehouse:prefix is the spotlight-layer markerpickHouseAdapplies at selection (10 §9 / #340 —ads.iditself stays a normal UUID); thisad_id LIKE 'house:%'check is the authoritative consumer and is unchanged. Never callsAuctionDOdirectly. - Live countdown to tomorrow’s auction —
LiveCountdown.astrois a mount point (<div data-island="live-countdown" data-auction-id="${tomorrow}">). The actual interactive island ships from 09; it connects towss://ws.nowyourlink.com/auction/${tomorrow}and renders bidder count + heat index (the privacy-safe signals defined in 06 §5). 07 only ships the SSR placeholder showing the plannedcloses_atfrom the D1 mirror. - Share buttons —
ShareButtons.astro:mailto:, Twitter/X intent, LinkedIn, copy-link. No JS frameworks; tiny inline handlers. - Like button —
LikeButton.astro(§9). Disqus slot— REMOVED (2026-06-13). The comment embed was dropped from the product (spec 09 §4); the homepage renders no comment thread. Item number retained so prior07 §3.5references resolve.- Footer — language switch, legal links, build hash.
The homepage SSR call hits D1 only, never AuctionDO. WS activity signals flow via the island (06), not through 07.
4. /day/{n} archive page
Section titled “4. /day/{n} archive page”Permanent record of the Spotlight for the Nth day since launch. URL maps to a calendar date via the D1 mirror (day_index integer column on daily_spotlight; n=1 is the first activated ad day).
src/pages/day/[n].astro SSR + edge-cache (s-maxage=86400, stale-while-revalidate=604800):
- Header — date, day number, winning amount, bidder count, view count (D1 aggregate).
- Winning ad block — rendered exactly as it appeared on the apex that day: image, title, description, target_url. The clickable link is removed after 30 days per PDF — link rendered only if
now - ad_day < 30d; after that, the target URL is displayed as plain text and not anchored. - Mini-sparkline —
Sparkline.astroSVG frombids_by_houraggregate row, server-rendered, no JS. - Bidder-count line + emoji bid-grid —
BidGrid.astrorenders one emoji per accepted bid (capped at 200); colours by relative amount band; tooltip shows hour-of-day. Server-rendered. Archived Disqus thread— REMOVED (2026-06-13) — Disqus dropped (spec 09 §4); the archive page renders no comment thread.- Prev/next nav —
/day/${n-1}and/day/${n+1}if within bounds. - Inter-links — “Record days” (top 5 by amount). (The “Most discussed” link, which ranked by Disqus comment count polled into
day_stats, is removed alongside Disqus.)
JSON-LD Article schema (§7), OG image points at https://cdn.nowyourlink.com/og/daily/${date}.png (§5). <link rel="canonical" href="https://nowyourlink.com/day/${n}">.
5. OG image generation
Section titled “5. OG image generation”Renderer choice. @resvg/resvg-wasm in a Worker route at apps/web/src/pages/api/og/[date].ts (research §1 confirms Astro endpoints can serve binary; deferred items in master §9 lists this as 07’s call). Resvg-wasm is the right pick: pure-WASM SVG→PNG, no Node deps, ~700 KB WASM cached at module scope, ~50–80 ms render for a 1200×630 PNG. We do not use Cloudflare Images at MVP — the cost (per-image transformation fees) is not justified for one PNG/day cached aggressively.
Pipeline.
- Request to
https://cdn.nowyourlink.com/og/daily/${date}.png. - Edge cache check (
caches.default.match(req)); hit → return. - R2 cache check —
OG_CACHE.get(\og/daily/${date}.png`)`; hit → cache.put + return. - Miss → fetch
daily_spotlightrow from D1 +bids_by_hourfor sparkline; build SVG via tagged-template literal (no full templating engine — keeps cold start under 5 ms);resvg.render(svg).asPng(). - Write to R2 (
OG_CACHE.put(...)), thencache.put(req, response.clone()), then return. - Daily cron at 00:10 UTC pre-warms today’s and tomorrow’s OG PNG (apex cron is in
apps/cron, not here; this spec defines only the route).
SVG layout (1200×630):
- Gradient header (brand colours).
- Brand mark top-left.
- “Day {n} · {date}” subtitle.
- Winner amount (
{eur}€), large, centred. - Bidder count + sparkline (40px high) below the amount.
- Footer:
nowyourlink.com/day/{n}.
Cache invalidation. R2 object key is date-scoped, so a re-render after a saga retroactively updates the row (rare) requires explicit purge: apps/api exposes an admin RPC purgeOG(date) that deletes the R2 key and cache.delete(req). Sitemap regen (§7) also nudges the URL once.
Edge cache header. Cache-Control: public, max-age=31536000, immutable, s-maxage=86400; once a day has elapsed and saga is settled, the PNG is effectively immutable.
6. Click-tracking redirect
Section titled “6. Click-tracking redirect”src/pages/go/[auction_id].ts is a Worker endpoint (not a page):
export const GET: APIRoute = async ({ params, request, locals, clientAddress }) => { const { env } = locals.runtime; const auction_id = params.auction_id!; // "YYYY-MM-DD"
// 1. Validate format + look up the spotlight row from D1 mirror const row = await env.NYL_D1.prepare( "SELECT target_url, status FROM daily_spotlight WHERE date = ?" ).bind(auction_id).first(); if (!row || row.status !== "settled") return new Response("Not Found", { status: 404 });
// 2. Compute anonymised visitor_id (research §9 pattern) const salt = await env.NYL_DAILY_SALT.get("today"); // KV, rotated daily by cron const ip = clientAddress.split(":").length > 2 ? truncateIpv6(clientAddress, 48) : truncateIpv4(clientAddress, 24); const ua = request.headers.get("user-agent") ?? ""; const visitor_id = await sha256(`${salt}:${ip}:${ua}`);
// 3. Fire-and-forget click log to api (Queues consumer writes to D1) locals.runtime.ctx.waitUntil(env.API.fetch("https://api.internal/_click", { method: "POST", body: JSON.stringify({ auction_id, visitor_id_hash: visitor_id, referrer: request.headers.get("referer"), device_hint: parseDeviceHint(ua), // "mobile" | "tablet" | "desktop" | "bot" ts: new Date().toISOString(), }), }));
// 4. UTM redirect const url = new URL(row.target_url as string); url.searchParams.set("utm_source", "nowyourlink"); url.searchParams.set("utm_medium", "spotlight"); url.searchParams.set("utm_campaign", auction_id); return Response.redirect(url.toString(), 302);};Idempotency / dedup. A click is “the same click” if (visitor_id, auction_id, hour_bucket) matches an existing row. The api consumer enforces this; 07 always logs; the consumer dedupes on insert. Daily salt rotation guarantees cross-day un-linkability (research §9).
Bot filter. cf.botManagement.score < 30 rows are tagged is_bot=true and excluded from advertiser-visible counts but kept in raw logs. Sustained abuse → Turnstile (§11). The redirect itself is never gated by Turnstile (it would break the user experience).
Service binding. env.API is a service binding to apps/api; never a public fetch().
7. SEO
Section titled “7. SEO”robots.txt— static, served frompublic/robots.txt. Allow everything by default;Disallow: /api/,Disallow: /go/,Disallow: /app/. Sitemap URL declared. Modelled after the world-forest “discovery” robots block (allow indexing, disallow ingest endpoints).sitemap.xml— SSR endpoint at/sitemap.xmlenumerating/,/about,/press,/legal/*, and all/day/${n}entries (one row perdaily_spotlightwherestatus='settled') with<lastmod>= the ad-day date. Pagination via<sitemapindex>only if entries exceed 50 000 (years away).- JSON-LD — every
/day/[n]emitsArticle(headline,datePublished= ad-day midnight UTC,image= OG URL,author={ Organization: nowyourlink }). Homepage emitsOrganization. - Open Graph —
og:imagepoints atcdn.nowyourlink.com/og/daily/{date}.pngfor/(today) and/day/[n].og:title,og:descriptionlocalised perAccept-Language. - hreflang —
<link rel="alternate" hreflang="en" href="https://nowyourlink.com/en/..."/>,de, andx-default→en. Locale prefix is optional on EN (default); explicit on DE (/de/day/...).
8. i18n
Section titled “8. i18n”Paraglide (D19, master). Default en. Locale resolution:
- URL prefix (
/de/...) → highest priority. nyl_localecookie.Accept-Languageparsing (first match against["en","de"]).- Fallback
en.
src/paraglide/messages/{en,de}.json keyed by dotted namespace (home.hero.title, day.bidGrid.tooltip). The LangSwitch.astro component writes the cookie and 302s to the prefixed URL. SSR sets <html lang="..."> from the resolved locale. Date and currency formatting via Intl.* with the resolved locale.
9. Like button
Section titled “9. Like button”Anonymous per-visitor, per-day. No auth.
- Visitor id. First visit sets cookie
nyl_visitorto a random UUID (SameSite=Lax, 1y). - POST
/api/like— body{ date, action: "like"|"unlike" }. Endpoint:- Reads
nyl_visitorcookie; if missing, sets one and proceeds. - Writes/deletes
NYL_LIKES.put(\like:${date}:${visitor_id}`, “1”, { expirationTtl: 31536000 })` (1y). - Increments/decrements
day_stats.like_countin D1 via a single statement (INSERT ... ON CONFLICT DO UPDATE). - Returns
{ liked: boolean, count: number }.
- Reads
- SSR initial state. Page render does a single
NYL_LIKES.get(\like:${date}:${visitor_id}`)` to render the correct toggle. - Spam resistance. If
like_countvelocity exceeds a threshold (>100/min for a given day), the endpoint requires a Turnstile token for the next 10 min (Turnstile site/secret keys are already in master §4.7).
Likes are a vanity metric; we intentionally do not rate-limit per IP harder than Turnstile — false positives are worse than mild inflation.
10. Performance & cost
Section titled “10. Performance & cost”- Adapter mode — single Worker per research §1 (
output: 'server', per-routeprerender). - D1-mirror reads only. Every public page reads from
daily_spotlight,day_stats,bids_by_hour— never AuctionDO. The DO is too expensive to wake per page render (master §10.2 — DO duration is a top-3 cost driver). - Edge cache — see §2 table.
/day/[n]is the workhorse and is cached for a day with SWR for a week. - R2 — backs OG PNG cache (out of scope here).
- Cloudflare Images — not used at MVP. Direct R2 + Cache API is cheaper for our one-PNG-per-day pattern; we revisit if user-uploaded variants are needed.
- Worker subrequest budget — each
/day/[n]render does 1 D1 read on a no-row miss, else 3 (daily_spotlight+bids+bids_by_hour, the latter two issued concurrently — #362), plus 1 KV read; well under the 50-subrequest cap (research §10). - Sentry —
@sentry/cloudflareinstrumented onapps/webper master §4.6; SSR-error route fingerprinted byroute_patternnotpathnameto avoid cardinality explosion.
11. Edge cases
Section titled “11. Edge cases”| # | Case | Handling |
|---|---|---|
| E1 | Auction has no winner (saga exhausted, house-ad fallback) | daily_spotlight.ad_id is a house-ad row; SpotlightCard renders normally. /go/{date} 404s for house ads (no UTM rewrite). |
| E2 | Winning ad revoked between activation and now | D1 mirror has ad_status='revoked'. Render a “This ad was retracted” stub on /day/[n] (same layout, content blanked). 03 admin tool sets the flag. |
| E3 | /day/0 (pre-launch) | 404 with friendly “We haven’t launched yet” page. day_index starts at 1. |
| E4 | Bot traffic on /go | Bot-scored requests are logged + tagged but still redirected (users behind weird CDNs look like bots; we don’t want false 403s). Advertiser counts exclude bots. |
| E5 | DST edge for /day/n mapping | We do not use local time. day_index is a monotonic integer over UTC dates; the calendar mapping never depends on DST. Display formatting uses the visitor’s locale separately. |
| E6 | KV miss on nyl_visitor like check (eventual consistency) | Treat as “not liked”; the next click flips state correctly. KV is eventually consistent (research §1) — acceptable for a vanity counter. |
| E7 | OG renderer cold-start spike | The cron at 00:10 UTC pre-warms today and tomorrow. Lazy renders cap at one concurrent build per date via a KV mutex (og:lock:{date} 10s TTL). |
| E8 | /go/{auction_id} with malformed auction_id | 404; never proxy unknown URLs. |
| E9 | Retired (2026-06-13) — Disqus removed (spec 09 §4). | |
| E10 | Locale cookie set to unsupported value | Fall back to en and rewrite the cookie. |
12. Acceptance criteria
Section titled “12. Acceptance criteria”pnpm --filter @nowyourlink/web devserves/,/day/1,/go/2026-06-04,/api/og/2026-06-04.pnginwrangler dev./day/[n]renders identical HTML for the samenacross reloads (cache stable)./go/{date}302s to the advertiser URL with UTM params; click log lands in D1 within 5 s.- OG PNG returns 200 < 200 ms after first generation; cron pre-warm yields cache HIT within the first render.
- Robots.txt and sitemap.xml validate; sitemap includes every
settledday. - Lighthouse SEO ≥ 95 on
/and/day/[n]. - Switching language via
LangSwitchpersists across reloads;<html lang>and JSON-LD reflect the chosen locale. - Liking + unliking flips correctly; refresh shows the persisted state via SSR.
- No page reads from AuctionDO (verified by grep + service-binding absence in
wrangler.jsonc). - Sentry captures a forced SSR error route-tagged
/day/[n].
13. Agent-team hints
Section titled “13. Agent-team hints”- Package wiring (research §2):
apps/webconsumes@nowyourlink/sharedfor D1 schemas + zod types. No DO classes here. - Service bindings:
API→apps/apifor click log + like aggregation;WS_HUBnot needed by 07 (the island in 09 connects directly tows.nowyourlink.com). - R2 binding:
OG_CACHE(private bucket), exposed publicly via acdn.nowyourlink.comcustom domain on the bucket (no extra Worker hop). - Wrangler: declare KV
NYL_LIKES,NYL_DAILY_SALT; D1NYL_D1; serviceAPI; R2OG_CACHE. Do not addAUCTIONSDO namespace here (research anti-pitfall §10). - Paraglide: integrate via the official Astro adapter; messages compiled into
src/paraglide/at build. - Tests: SSR golden tests for
/day/[n]markup + JSON-LD; snapshot tests for OG SVG (pre-resvg) to keep visual diffs reviewable; unit tests fortruncateIpv4/truncateIpv6+ UTM rewrite. - No push JS in this package — only mount points. (Disqus and its consent banner were removed — spec 09 §4.)
- Locale assets ship with the worker bundle; keep
messages/*.jsonunder 30 KB each.