Open Library Cover Egress
Open Library Cover Egress
CHANGE SUMMARY
- Added (2026-06-10, SUR-492 + SUR-566): documents the client → Open Library egress boundary for book cover art — the data that leaves, the trigger points (SUR-198 per-create/edit, SUR-490 back-fill, SUR-566 ISBN heal), the runtime kill-switch (
app_config.openlibrary_egress, migration0038), and the operational flip procedure.- Updated (2026-06-11, SUR-491): the OCR detected-source “Add and select” path now resolves covers too — added as trigger point §2.4. It previously bypassed resolution; it now delegates to the same
addBookDirectlycreate path, so it joins the gated egress population (no new ungated path).
Surfc resolves book cover art client-side against Open Library. This is the only third-party egress on the app surface other than the managed-AI proxy, PostHog, Stripe, Termly, and Turnstile — and unlike those it is issued directly from the browser to a host Surfc does not control. This page is the boundary’s single source of truth: what leaves, when, and how to halt it.
Related: Cloud Boundary ·
Data Architecture → books ·
Operations → Runbooks.
1. The boundary — what leaves, what returns
| Direction | Data | Endpoint |
|---|---|---|
| Out | A book’s title, author, and/or isbn (never note text, never PII) | https://openlibrary.org/search.json |
| In | A cover image (rendered in an <img>; cached by the service worker + CDN) | https://covers.openlibrary.org/b/... |
Resolution is ISBN-first (src/lib/coverResolver.js): with a usable ISBN we
build the Covers-API URL directly (/b/isbn/<isbn>-M.jpg?default=false) with no
network call — the <img> itself is the only request, and it 404s to the
placeholder tile if the edition has no cover. With no ISBN we hit search.json
once on title (+author), take the top result’s cover_i, and self-heal its first
ISBN back onto the record. The resolver never throws into its caller.
Rate-limit note: /b/isbn/ Covers-API lookups are IP-rate-limited by Open
Library (~100 / 5 min); /b/id/ (cover-id) lookups are not — a reason the
SUR-566 heal prefers cover-id URLs.
2. Egress trigger points
All four funnel through resolveAndPersistCover in src/hooks/useAddSource.js
— the single chokepoint where the kill-switch (§3) is enforced.
- Per-create / per-edit resolution (SUR-198). Adding a source, or editing its
title/author/ISBN, fires one lookup off the critical path.
coverSource: 'manual'pins (a pasted cover URL) are never overwritten. - Back-fill sweep (SUR-490). A one-time, throttled (1 req/s, serial)
pass over pre-SUR-198 sources that still lack a cover. Explicit first opt-in
(
start()), then a silent auto-resume on later loads until the library is fully attempted;coverResolvedAtstamps a no-match so a row is never re-queried. Orchestrated bysrc/hooks/useCoverBackfill.js. - ISBN-404 heal (SUR-566). Open Library attaches covers to works, not
editions, so an ISBN whose edition record has no cover 404s with no
fallback. When a persisted
/b/isbn/cover fails to load in the Library list,SourceCover’sonErrorheals it once via an ISBN-keyed Search lookup (q=isbn:<isbn>) onto the work-level/b/id/cover (or clears the broken URL on a true no-match). At most onesearch.jsonper broken cover, ever;loading="lazy"spreads heals to ~viewport-worth as the user scrolls, so it is not a library-wide burst. - OCR detected-source add (SUR-491). The capture flow’s “detected source”
banner (“Add and select”) hand-rolled its own book record and never resolved
a cover — books added that way were the one create path that stayed on the
placeholder.
addDetectedBook(src/hooks/useNoteForm.js) now delegates toaddBookDirectly, so a detected source is seeded with the full isbn/cover field set and resolves a cover like trigger #1. This widens the egress population (more sources now do one lookup), not the egress mechanism — it reuses the same gated chokepoint and one-lookup-per-source posture. Lands after SUR-492 (born gated) and SUR-566 (so its new stream hits the healed resolver).
Polite-use posture: attribution is rendered in the UI (“Cover art from Open Library”); we do not crawl at scale.
3. The kill-switch — app_config.openlibrary_egress
A global, founder-controllable flag gates all of the above, so the egress can be paused without a client deploy (e.g. a provider-side rate complaint or ToS issue). Two consecutive egress security reviews (SUR-198, SUR-490) carried the missing kill-switch as a residual risk; SUR-492 built it.
Mechanism (mirrors the SUR-246 content-safety dark-launch pattern, but global and read-only):
- Source of truth: a single row in the new
public.app_configtable (migration0038_sur492_app_config.sql),key = 'openlibrary_egress',valuejsonb{"enabled": <bool>}. Client-readable (anon + authenticated, SELECT policy), service-role-only to write — no client can flip it. - Client read:
fetchAppConfig(src/supabase.js) reads it;useOpenLibraryGate(src/hooks/useOpenLibraryGate.js) mirrors it to alocalStoragecache, server-wins;isEgressEnabled()(src/lib/openLibraryGate.js) is the synchronous read the cover paths gate on. - Default ON, fails open. A missing table / unreachable row / parse error reads as enabled, so the flag never disables covers on a transient error (it gates a feature it does not own). Default is ON on every axis (seed row, fail-open read, cache miss).
- In-session propagation. The flag is re-read on tab-visibility regain, on reconnect, and on a bounded (~5-min) poll — so a live flip reaches already-open tabs without a reload: an in-flight back-fill aborts and stops auto-resuming when it goes OFF, and resumes when it goes back ON.
Scope of the kill (important). Flipping OFF stops new lookups.
Already-persisted cover URLs keep rendering via <img> from the CDN/cache — by
design (so the shelf doesn’t go blank). If an incident is about image
bandwidth rather than API/search volume, a repo-wide img-src https: CSP is
the complementary backstop (carried forward as a separate ticket; no CSP exists
in the repo today).
4. Operational runbook — pause / resume the egress
Run as the service role (Supabase Dashboard → SQL editor). The client has no write path.
Pause (halt all new Open Library lookups):
UPDATE public.app_config SET value = '{"enabled": false}', updated_at = now() WHERE key = 'openlibrary_egress';Resume:
UPDATE public.app_config SET value = '{"enabled": true}', updated_at = now() WHERE key = 'openlibrary_egress';Verify the current state:
SELECT value FROM public.app_config WHERE key = 'openlibrary_egress';Propagation: already-open tabs pick the change up in session (visibility /
reconnect / ≤ ~5-min poll); a cold load applies it immediately. No deploy
required either way. Migration 0038 is applied by hand (Dashboard SQL); it
seeds the flag ON, so applying it is a no-op behaviourally.
5. File map
| Concern | File |
|---|---|
Resolver (ISBN-first + title search + SUR-566 resolveCoverByIsbnSearch) | src/lib/coverResolver.js |
Egress chokepoint + kill-switch gate + heal persistence + addBookDirectly (silent) | src/hooks/useAddSource.js (resolveAndPersistCover) |
OCR detected-source add (delegates to addBookDirectly, SUR-491) | src/hooks/useNoteForm.js (addDetectedBook) |
Back-fill orchestration + healCover | src/hooks/useCoverBackfill.js |
| Synchronous flag read + cache | src/lib/openLibraryGate.js |
| Server read + in-session reconciliation | src/hooks/useOpenLibraryGate.js |
| Global config fetch | src/supabase.js (fetchAppConfig) |
Cover render + onError heal trigger | src/components/SourceRow.jsx (SourceCover) |
| Config table + RLS/grants + seed | supabase/migrations/0038_sur492_app_config.sql |
Gating: the resolver is the third-party-egress module (GATING §5 rule 3,
GCE-escalated for SUR-198); the app_config migration + src/supabase.js are
spine (migration-reviewer + security-reviewer). The client gating logic is
surface/CE with security-reviewer confirming the kill path actually halts egress.