Skip to content

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, migration 0038), 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 addBookDirectly create 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

DirectionDataEndpoint
OutA book’s title, author, and/or isbn (never note text, never PII)https://openlibrary.org/search.json
InA 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.

  1. 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.
  2. 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; coverResolvedAt stamps a no-match so a row is never re-queried. Orchestrated by src/hooks/useCoverBackfill.js.
  3. 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’s onError heals 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 one search.json per broken cover, ever; loading="lazy" spreads heals to ~viewport-worth as the user scrolls, so it is not a library-wide burst.
  4. 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 to addBookDirectly, 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_config table (migration 0038_sur492_app_config.sql), key = 'openlibrary_egress', value jsonb {"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 a localStorage cache, 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

ConcernFile
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 + healCoversrc/hooks/useCoverBackfill.js
Synchronous flag read + cachesrc/lib/openLibraryGate.js
Server read + in-session reconciliationsrc/hooks/useOpenLibraryGate.js
Global config fetchsrc/supabase.js (fetchAppConfig)
Cover render + onError heal triggersrc/components/SourceRow.jsx (SourceCover)
Config table + RLS/grants + seedsupabase/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.