Historical context from before the saved-link simplification. For the active product and setup, see architecture.
Concrete lessons evidenced by current code, scripts, or git history. Each
links to the decision record where relevant. Pre-Vite lessons (OpenNext
patch scripts, next.config.ts, WeakRef patches, etc.) are preserved in
archive/lessons.md — they describe
code that no longer exists but capture useful failure modes.
src/worker.ts runs bindWorkerEnv(c.env) on every /api/* request so the
lazy db proxy (src/lib/db/client.ts) and the pdfsBucket singleton
(src/lib/storage.ts) resolve the runtime env before first use. Eager
instantiation at module load fails in the Workers runtime where the env is
not yet available at import time.
→ architecture/decisions/0002-turso-drizzle.md, architecture/decisions/0003-r2-pdfs.md
articles-db.ts caches article reads at the edge (5 min TTL) using
globalThis.caches?.default. The guard is required because caches.default
does not exist in some local-dev runtimes (e.g. the pure-Vite SPA dev server).
Cache must be explicitly busted on writes (see lists-db.ts).
wrangler.toml has [placement] mode = "smart". Without it, the Worker runs
in a Cloudflare PoP that may be far from the Turso primary, paying a
cross-region RTT on every request. A psi-swarm audit flagged TTFB >1s as the
dominant LCP contributor before Smart Placement was enabled.
→ architecture/decisions/0002-turso-drizzle.md
wrangler.toml sets run_worker_first for /sitemap.xml, /index.md,
/llms-full.txt, /llms.txt, /api/*, and /. Without it, the ASSETS
binding serves static files first and the Worker never sees the agent-edge
or API paths.
pnpm db:push diffs schema.ts against the live DB and applies the diff.
Safe for a single-user DB; fragile if a push runs against production with
data in an incompatible old shape. Additive migrations are committed under
drizzle/ and applied deliberately. Open question: switch to
drizzle-kit generate as user count grows (tracked in STATUS.md).
→ operations/runbooks/migrate-schema.md
src/lib/db/schema.ts still defines account, session, verificationToken
(leftover from the Auth.js → better-auth swap). They are unused by
better-auth. Safe to drop after confirming no active rows. Open question,
tracked in STATUS.md.
→ architecture/decisions/0004-better-auth-google.md
tags, notes, aiChat, summary, keyPoints, pdfMetadata,
sessionReview are stored as text and typed via $type<T>() for TS safety.
No row-level JSON filtering in current query patterns; json_each is
available if needed.
src/lib/pdf-service.ts and the upload route do not trust file.type
(browser-controlled and spoofable). Read the first 5 bytes and check for
%PDF- before accepting the file.
→ architecture/decisions/0003-r2-pdfs.md
PDFs don't have an HTTP URL. The upload stores blob://<storageKey> as the
articles.url field so the (user_id, url) unique index does not collide
with real HTTP article URLs.
BYOK provider keys are sent per-request from the client and used immediately
— never written to the database or logs. normalizeApiKey() in
ai-server.ts trims and length-limits the key before use. Extension AI
calls use hashed long-lived rdr_* API keys instead.
→ architecture/decisions/0005-ai-gateway-byok.md
All AI requests include x-gateway-project-id: reader. Without it the
free-ai gateway cannot distinguish Reader from other fleet projects and
cannot attribute the 9500 Neuron/day fleet-wide cap.
pdfjs-dist defaults to loading its web worker from a CDN. This fails
in the extension context (CSP script-src 'self' blocks remote scripts)
and in the CF Workers build sandbox. In the webapp, src/components/PDFViewer.tsx
sets GlobalWorkerOptions.workerSrc to
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), so Vite
bundles the worker locally into dist/assets/ instead of fetching it remotely.
→ architecture/decisions/0007-content-extraction.md
MV3 extensions cannot share the same-origin session cookie. The api_keys
table stores SHA-256-hashed tokens (sha256Hex in src/lib/api-keys.ts) with
a visible rdr_ prefix. The extension
sends the raw token as a Bearer header; the server hashes it for lookup.
→ architecture/decisions/0006-mv3-side-panel.md
/api/snapshot, /api/proxy, and RSS refresh use
validateExternalUrl() (src/lib/url-validation.ts) +
fetchWithValidatedRedirects() (src/lib/safe-fetch.ts). The latter
re-validates every redirect target before following it, so a public URL
cannot bounce the server into a private network hop after the first request.
DNS rebinding note: validation resolves DNS once, but fetch may re-resolve
later. Full protection would require a custom fetch agent that pins the
resolved IP (not available in the Workers fetch API). Appropriate for a
personal-use reader; revisit if the server becomes privileged.
→ architecture/data-flow.md, archive/security-audit-2026-03-29.md
Content is sanitised before storage and re-sanitised in
fetchArticleById() / fetchArticleByShareId() for defence-in-depth. Do
not remove the read-time sanitisation even though ingestion sanitises — it
guards against a future ingestion bypass or stale data.
→ archive/security-audit-2026-03-29.md
scripts/overlay-astro-landing.mjs copies landing-astro/dist/* over
dist/ except for protected prefixes (assets/, app.html). _headers is
merged. The SPA is served at /app; the landing at /. SPA fallback for
unknown paths goes to /app (handled in src/worker.ts).
The Worker is named reader in wrangler.toml. The custom domain
(read.significanthobbies.com) and all Cloudflare secrets are bound to that
name. Do not rename it without re-provisioning secrets and the route.