This document is the plan for migrating the Bloom Library API from bloom-azure-functions (Azure Functions, Node/TypeScript) to this repo (Supabase Edge Functions, Deno/TypeScript).
Status as of July 2026: fs is migrated (this repo's first function) and serving production
traffic through the proxying Cloudflare Worker (Phase 0.5, deployed 2026-07-10). Everything
else still runs on Azure.
All clients call https://api.bloomlibrary.org/v1/<function>. Known consumers:
| Consumer | Endpoints used |
|---|---|
| BloomLibrary2 (blorg) | books (queries), stats, social (share links), fs (book artifacts/thumbnails) |
Bloom Desktop (BloomLibraryBookApiClient.cs) |
books (:upload-start / :upload-finish), status, books/{id}:permissions |
| External OPDS partners (API accounts) | opds (which emits links back into fs) |
| Social-media scrapers (Facebook, etc.) | social (also via social.bloomlibrary.org/v1/social) |
| Function | Trigger | Mutating? | External dependencies | Auth |
|---|---|---|---|---|
fs ✅ migrated |
HTTP GET | No | ParseServer, S3 (public GET) | none |
social |
HTTP GET | No | none (pure HTML) | domain allow-list |
subscriptions |
HTTP GET (/v1/subscriptionInfo/{code}) |
No | Google Sheets (service account) | none |
opds |
HTTP GET | No | ParseServer (incl. catalog-service login) |
API-account key param |
stats |
HTTP GET/POST | No (temp table only) | Postgres analytics DB (read-only user), ParseServer | none |
contentfulToCrowdin |
Timer (22:30 daily) | Yes (writes to Crowdin) | Contentful (read), Crowdin (write) | tokens |
dailyTimer |
Timer (10:40 daily) | Yes (refreshes PG materialized views) | Postgres (admin user) | n/a |
bookCleanup |
Timer (12:40 daily) | Yes — deletes S3 files and Parse records | ParseServer (book-cleanup login), S3 |
password |
status |
HTTP GET | No | Azure Durable Functions state | none |
books |
HTTP GET/POST/DELETE | Yes (Parse + S3 writes) | ParseServer, S3/STS, Contentful, Durable Functions | Parse session token |
longRunningActionOrchestrator / longRunningActions |
Durable (internal) | Yes (does the upload work) | ParseServer, S3 | internal |
- One deployed edge function:
fs(supabase/functions/fs/), Deno,verify_jwt = false. - Shared code in
supabase/functions/_shared/—BloomParseServer.tswas ported wholesale from the Azure repo, so much of the plumbing for future functions (book CRUD, service-user logins, S3 link helpers) already exists here, though only a small slice is currently used. - CI/CD: GitHub Actions. PRs run tests; push to
developdeploys to the staging Supabase project; push tomaindeploys to production (.github/workflows/). - Secrets so far:
BLOOM_PARSE_APP_ID_{PROD,DEV,UNIT_TEST}plus deploy credentials.
2026-07-10 update: the 302 Redirect Rules described below have since been replaced by the proxying Worker (Phase 0.5 ✅ — see Appendix A for the current routing). This section is kept as background on why the replacement was necessary.
What we knew (verified by inspecting live traffic, 2026-07-08):
-
All
api.bloomlibrary.orgtraffic goes through Cloudflare. -
The
fssplit is a redirect ("forwarding"), not a proxy. It is implemented as two Cloudflare Redirect Rules (confirmed from the dashboard, 2026-07-08):supabase edge functions: hostapi.bloomlibrary.org+ path starts with/v1/fs/→ 302 toconcat("https://sekpsuviwfhzzznzrdgx.supabase.co/functions/v1/fs/", ...)supabase edge functions - staging: hoststaging-api.bloomlibrary.org+ path starts with/v1/fs/→ 302 toconcat("https://mwatyhkxsprxgnkcbalq.supabase.co/functions/v1/fs/", ...)
So there is a staging API hostname (
staging-api.bloomlibrary.org→ the staging Supabase project) alongside production. Verified live:GET https://api.bloomlibrary.org/v1/fs/*returns302 Foundfrom Cloudflare with the path rewritten/v1/→/functions/v1/; the client then fetches from Supabase directly. -
Everything else is reverse-proxied to Azure: the
api.bloomlibrary.orgDNS record points atbloom-functions.azurewebsites.net(proxied through Cloudflare). Verified live — e.g./v1/subscriptionInfo/*returns the Azure response directly (Azure'sRequest-ContextApp Insights header present, no redirect). -
social.bloomlibrary.orgalso routes to the Azuresocialfunction.
Why forwarding works for fs but not for most other functions: fs is public, GET-only,
and called with no custom headers — a CORS "simple request" that never triggers a preflight, and
every client (browser, curl, <img> tags) follows GET redirects transparently. The other
functions break one or more of those assumptions; see §2a below. The plan assumes we replace
the redirect with a true proxy before migrating anything beyond fs.
Still to be looked up and documented here: how social.bloomlibrary.org is routed
(needed for Phase 1); everything else is now documented in Appendix A.
This routing layer is the cutover/rollback lever for the whole migration: each function migrates
by adding one entry to the worker's SUPABASE_FUNCTIONS list, and rolls back by removing it.
It is version-controlled in cloudflare/worker.js (deployed by the ops
team); keep Appendix A current as routes change.
Problems with 30x forwarding for the remaining functions:
- CORS preflights cannot follow redirects. Any browser request with a JSON body or a custom
header (
Authentication-Token,Content-Type: application/json) triggers anOPTIONSpreflight, and the fetch spec treats a redirect response to a preflight as a network error. This breaks blorg's calls tostats(POST + JSON) andbooks(auth header) outright. - 302 converts POST to GET. Browsers and most clients re-issue a 302'd POST as a bodyless
GET, breaking
books:upload-start/finishandDELETE books/{id}. A 307/308 preserves the method, but only for clients that can replay the body, and it doesn't fix problem 1 or 3. - Auth headers are dropped on cross-origin redirects. The fetch spec strips
Authorizationwhen following a redirect to a different origin, and .NET'sHttpClient(Bloom Desktop) does the same — risky for any authenticated endpoint even where the redirect is otherwise followed. - Unknown clients may not follow redirects at all — OPDS partner harvesters, social-media
scrapers hitting
social, older Desktop code paths. - It leaks the Supabase project URL into clients, caches, and logs, and adds a full extra
round-trip (new TLS connection to
*.supabase.co) per request.
Recommendation: replace the redirect with a Cloudflare Worker on
api.bloomlibrary.org/v1/* that proxies (fetch()) matching paths to
<project-ref>.supabase.co/functions/v1/... and passes everything else through to Azure. Workers
are available on any Cloudflare plan (the alternative — Origin Rules with host-header override to
an external origin — is Enterprise-only), keep api.bloomlibrary.org as the only origin clients
ever see, preserve method/headers/CORS semantics, and give us a per-path routing table we can
version-control with Wrangler. Migrating fs from the 302 rule into that Worker is the proof
step, and becomes Phase 0.5 — a prerequisite for every later phase.
The worker is deployed (2026-07-10): bloom-api-router-staging and bloom-api-router
are live on staging-api.bloomlibrary.org/v1/* and api.bloomlibrary.org/v1/*, currently
routing only fs to Supabase. Code, Wrangler config, current state, and
verification/rollback instructions are in cloudflare/. (The dev
team has only domain-level Cloudflare permissions, so the ops team deploys changes; each
later phase is a one-line change to the worker's routing list, staging worker first.)
Risk here combines: implementation complexity, blast radius if it breaks, whether it mutates data, and how easily it rolls back (all HTTP functions roll back instantly via the Cloudflare rule; timers roll back by re-enabling the Azure timer).
The API contract. api-spec.yml — the OpenAPI spec for the public API at
https://api.bloomlibrary.org/v1, ported from the Azure repo — is landed first, ahead of
every phase below, as the reference each migration must preserve. The phases move each endpoint's
implementation from Azure to Supabase; the contract they honor does not change. Everything in
api-spec.yml is live; planned-but-unimplemented endpoints (currently only /languages/{tag},
never implemented in either repo) live in api-spec-next.yml and graduate
to api-spec.yml when built.
Serving production. Remaining follow-ups: document the Cloudflare rule (Appendix A), and confirm
Azure fs traffic has actually dropped to zero before deleting the Azure function.
Stand up the Cloudflare Worker described in §2a in two stages: first replace the staging
redirect rule (staging-api.bloomlibrary.org → staging Supabase project) and verify proxying
behavior there (headers, range requests, caching, Azure fall-through); then replace the
production rule on api.bloomlibrary.org. Every subsequent phase is then "add a path to the
Worker's routing table," rolled out staging-first the same way.
Status: ✅ deployed by ops 2026-07-10 — both workers live, routing only fs for now; both
redirect rules disabled (kept for rollback). Verified staging and production: 200 PNG through
the proxy with no 302, 206 on Range requests, and Azure fall-through intact
(Request-Context header present). See cloudflare/ for current
state, verification curls, and the per-function extension process.
- Pure HTML generation with a
bloomlibrary.orgdomain allow-list; no external services, no secrets, read-only. - Watch-outs: preserve exact OpenGraph output (test with Facebook's sharing debugger);
social.bloomlibrary.orgneeds its own Cloudflare rule in addition toapi.bloomlibrary.org/v1/social. - Status: implemented (
supabase/functions/social/) and merged todevelop. Two changes from the Azure version: parameters are HTML-escaped (the Azure version interpolated them raw — an injection vector), and the publicog:urlis reconstructed from a custom header the worker sends (see theog:urlfinding below). No special cutover ordering is needed.
When the worker proxies a migrated function it must rewrite the request host to
<ref>.supabase.co — you cannot reach Supabase under the bloomlibrary.org host. So the
function sees its own URL as the Supabase one and can't build a public og:url from req.url.
The code tries to carry the original host across in an X-Forwarded-Host header the worker
sets — but that header does not survive. X-Forwarded-* are proxy-managed headers, and the
Cloudflare/gateway layer in front of the Supabase runtime strips or overwrites them before the
Deno function sees them. Verified on staging: a curl that set X-Forwarded-Host by hand,
directly at *.supabase.co (bypassing the worker entirely), still didn't reach the function —
so the loss is at the Supabase edge, not in our worker. Result: og:url silently falls back to
the *.supabase.co URL — exactly the leak §2a-5 warns about.
Current production behavior (verified 2026-07-17). Production is unaffected today because it
is still on Azure. Both public entry points — api.bloomlibrary.org/v1/social and
social.bloomlibrary.org/v1/social — hit the same Azure function (identical Request-Context
appId) and differ in exactly one way: each mirrors its own host into og:url (api.… →
https://api.bloomlibrary.org/v1/social?..., social.… →
https://social.bloomlibrary.org/v1/social?...). Azure gets this for free because the request
reaches it already bearing the public host. Preserving that per-host mirror is the target
behavior for the migrated version.
Second, latent bug found the same way: getPublicUrl strips a /functions/v1/ path prefix, but
the function's actual req.url path is /social (no such prefix), so even a surviving header
would rebuild /social, not the /v1/social Azure emits.
Resolution (implemented): carry the public URL in a custom header. X-Forwarded-* is
stripped, but a non-proxy-managed header rides through the Supabase edge untouched. Verified on
staging 2026-07-17: with X-Bloom-Public-Url set to a social.bloomlibrary.org URL, og:url
came back as that public URL with no *.supabase.co — both when routed through the existing
worker (which copies client headers) and when sent directly to the Supabase function. So:
- Function side:
getPublicUrlreads a full public URL fromX-Bloom-Public-Url, validated against thebloomlibrary.orgallowlist, and otherwise falls back toreq.url. Carrying the full URL also disposes of the/functions/v1/vs/v1/socialpath bug above, since the worker sends the already-correct public path. (The deadX-Forwarded-Hostpath has been removed.) - Worker side (needs an ops deploy of
cloudflare/worker.js): the routing worker setsX-Bloom-Public-Urlto the original public request URL (host +/v1/…+ query) in place ofX-Forwarded-Host.
This supersedes the earlier idea of a DNS/custom-domain switch: no Supabase custom domain is
needed, and social need not be cut over last — it works correctly through the worker like any
other migrated function, on both api.bloomlibrary.org and social.bloomlibrary.org (each
mirrors its own host, since the worker forwards whichever public URL the request arrived on).
Remaining step: an ops deploy of the updated cloudflare/worker.js to Cloudflare (staging, then
production). Until then, deployed functions simply fall back to req.url (no regression).
- Route
/v1/subscriptionInfo/{code}; reads one named range from a Google Sheet. - Needs the Google service-account secrets moved to Supabase
(
BLOOM_GOOGLE_SERVICE_ACCOUNT_EMAIL,BLOOM_GOOGLE_SERVICE_PRIVATE_KEY,BLOOM_SUBSCRIPTION_SPREADSHEET_ID). Thegoogleapisnpm client works under Deno vianpm:specifiers, or swap for a plain JWT + fetch call to the Sheets REST API. - Consumed by Bloom Desktop for subscription/branding codes, so verify against a released Desktop build before cutover.
- Daily one-way sync: Contentful → three l10n JSON files → Crowdin. No Bloom data touched; a failure is visible only to translators and is easy to re-run.
- Supabase Edge Functions have no timer trigger. Options:
a. pg_cron + pg_net in the Supabase Postgres calling the edge function on schedule
(the standard Supabase pattern), or
b. a GitHub Actions
schedule:workflow in this repo that invokes the function (simpler, visible in git, one less moving part in the database). Pick one and reuse it for the other timers. - Run both Azure and Supabase versions in parallel for a few days (Crowdin uploads are idempotent) before disabling the Azure timer.
- ParseServer-only dependency, and
_shared/BloomParseServer.tsalready has thecatalog-servicelogin helper ported. Main work is porting the XML catalog generation and its tests. - Risk is the external contract: OPDS partners consume this XML with their own parsers.
Mitigate with byte-level diffing of old vs. new output across a matrix of
lang/tag/epub/srcparameters before cutover. - Needs
BLOOM_PARSE_CATALOG_SERVICE_PASSWORD(+ unit-test API-account id for tests).
- Calls stored procedures in the analytics Postgres DB (read-only user), sometimes seeding a temp table with book IDs resolved from Parse.
- New for this repo: direct Postgres connections from an edge function to the analytics DB
(not the Supabase DB). Use a Deno Postgres driver; check that the analytics DB accepts
connections from Supabase egress IPs, and prefer a pooled connection (the Azure version manages
a
pgpool; edge functions are short-lived, so verify connection-per-request performance or put pgbouncer in front). - Note:
stats/events.tsbuilds some SQL by string interpolation with only a crude injection guard — fix that (parameterized queries) during the port rather than copying it. - Consumed by blorg's book-stats UI; low write risk, moderate visibility.
- One job: call
common.refresh_materialized_views()in the analytics Postgres as the admin user. Chosen approach: schedule it inside the analytics Postgres itself with pg_cron and skip porting the function entirely — it's a DB-to-itself operation; the Azure function is just a cron wrapper. The analytics DB is Azure Database for PostgreSQL (bloom-analytics.postgres.database.azure.com); pg_cron is on its supported-extensions list (requires addingpg_cronto theazure.extensionsandshared_preload_librariesserver parameters — needs a server restart — and settingcron.database_name). - Failure reporting (pg_cron only records outcomes, it never pushes alerts):
- pg_cron logs every run in
cron.job_run_details(status, error message, timings) — the audit trail, queryable but passive. Schedule a second pg_cron job to prune it (e.g. delete rows older than 30 days). - Azure's Postgres flavor does not support
pg_net/httpextensions, so the DB cannot call a webhook itself. Active alerting therefore comes from a small external watchdog: a scheduled GitHub Actions workflow in this repo (daily, an hour or two after the refresh) that connects with the read-only user and asserts freshness — every materialized view refreshed within the last ~25 h — rather than looking for failure rows (a freshness check also catches "pg_cron never ran at all", which leaves no failure row). A failing workflow notifies via GitHub's built-in failure emails (optionally Slack). - To make freshness checkable, extend
common.refresh_mv_in_transaction()(inanalytics-postgreSQL) to write each view's refresh time + success/error to a smallcommon.refresh_logtable. Worth adding per-view exception handling at the same time so one failing view doesn't abort the remaining six (today an error mid-procedure stops the rest). - Alternative/adjunct if ops prefers: Azure Monitor log alerts on the Postgres server logs.
- pg_cron logs every run in
- Note this is a better story than the status quo: the Azure
dailyTimeronly logs failures to App Insights, which nobody is alerted on either. - Risk: needs the server-parameter change (restart) on the analytics DB. Failure mode is stale stats (detectable, recoverable), not data loss.
- Deletes abandoned-upload S3 files and Parse book records older than 24h. First function that can destroy data if buggy.
- Mitigations: it already has a
runInSafeModeflag — run the Supabase port in safe/log-only mode for a week alongside the Azure version, diff what each would delete, then flip. Never run both live simultaneously (double-delete races on the same books). - Needs
BLOOM_PARSE_BOOK_CLEANUP_PASSWORD_{PROD,DEV,UNIT_TEST}and theBLOOM_UPLOAD_PERMISSION_MANAGER_S3_*secrets.
- The core book upload/query/delete API: Parse session-token auth, uploader/collection-editor/
moderator permission logic (Contentful), S3 copy/delete, STS temporary credentials, and —
hardest — the Azure Durable Functions pattern (
books:upload-start/finish→ orchestrator → activity, polled viastatus, 10-minute timeout). - Supabase edge functions can't replicate durable orchestration directly (CPU/wall-clock limits,
no built-in orchestration). The 202-Accepted + poll-
statuscontract must be re-implemented; realistic options, to be decided in a short design doc when we get here:- a jobs table in Supabase Postgres + Supabase Queues (pgmq), with a worker triggered by
cron or
EdgeRuntime.waitUntilbackground tasks; - restructure upload-finish so the slow parts (S3 copy, hash diffing) shrink or move client-side, possibly eliminating the need for orchestration;
- keep the orchestration piece on Azure last, migrating the simple
booksGET/permissions endpoints first (thebooksroutes can be split at Cloudflare by method/action if needed).
- a jobs table in Supabase Postgres + Supabase Queues (pgmq), with a worker triggered by
cron or
statusmigrates with this phase — it's meaningless apart from the orchestration store.- Both current Bloom Desktop releases and blorg depend on this; Desktop versions in the field
can't be patched, so the API contract (including error shapes and
Operation-Locationheaders) must be preserved exactly. - Migrate
booksGET (blorg queries) first behind its own Cloudflare rule, then uploads.
When Azure traffic is zero (verify via Azure metrics/logs over ≥30 days, remembering monthly timers and long-tail OPDS clients): disable the functions app, retire the Kudu deployment, rotate any credentials that lived in Azure app settings, archive the repo with a pointer to this one.
What remains relevant in the Azure repo (everything else — functions, shared code, tests, api-spec, host/deploy config, READMEs — has been ported here or superseded):
- The secret values in the Function App's settings (portal → Configuration → Application
settings; also on dev machines in
local.settings.json) — the source when provisioning the checklist in §4. Never in git. - The running app itself until cutover completes.
- The unit-test Parse server (
bloom-parse-server-unittest.azurewebsites.net) is a separate Azure App Service from thebloom-parse-serverrepo — out of scope; it stays.
- Runtime port (Node → Deno): axios →
fetch;@azure/functionsrequest/response → theDeno.servehandler pattern already established infs; npm deps vianpm:specifiers where needed. The?env=devenvironment-selection convention is already ported (_shared/utils.ts). - Routing convention: Azure uses
host.jsonroutePrefix: "v1"; Supabase functions live at/functions/v1/<name>. Follow thefspattern (scan path segments for the function-name marker) so functions work both at their native Supabase URL and behindapi.bloomlibrary.org/v1/.... - Secrets: each phase moves its secrets into Supabase project secrets (staging + prod) and
GitHub Actions secrets for CI. Full inventory is in the Azure repo (
local.settings.jsonkeys); map naming to theBLOOM_*convention started here. - JWT: each new public function needs
verify_jwt = falseinsupabase/config.toml(or an explicit auth design if we ever want Supabase-native auth). - Testing: keep the ported Jest tests as Deno tests (pattern established in
supabase/functions/tests/). The unit-test Parse Server still runs on Azure App Service (bloom-parse-server-unittest.azurewebsites.net) — out of scope here, but note it's a residual Azure dependency even after this migration completes. - Staging: every phase deploys to the staging Supabase project first (auto on
develop) and is exercised there before the Cloudflare rule flips production traffic. - Rollback: for HTTP functions, rollback = revert the Cloudflare rule (leave the Azure function running until decommission). For timers, rollback = re-enable the Azure timer. Keep the Azure app deployed and warm until the very end.
- Monitoring: decide per-phase how we'll know it broke — Supabase function logs/metrics at minimum; consider forwarding errors to whatever alerting the team already watches.
All code is implemented (phases 1–5, 7, 8, the worker, and the spec). What remains is operational, in this order:
- Merge the branch stack bottom-up into
develop(api-spec→phase2-subscriptions→phase3-contentful-to-crowdin→phase4-opds→phase5-stats→phase7-book-cleanup→phase8-books;phase1-socialis already ondevelop). Each merge auto-deploys functions and migrations to the staging project. Before the first merge, provision the secrets checklist (§4) on the staging Supabase project and the GitHub repo secrets (BLOOM_CRON_SECRET,BLOOM_SUPABASE_{STAGING,PRODUCTION}_DB_PASSWORD) — the contentfulToCrowdin cron activates on merge and will report failures until its secrets exist. - Ops deploys the staging worker (Stage 1 of
cloudflare/README.md). - Verify on
staging-api.bloomlibrary.org: every function's smoke tests; the OPDS byte-diff against Azure output (lang/tag/epub/srcmatrix); a real Bloom Desktop upload (upload-start → S3 sync → upload-finish → status polling);statsagainst the analytics DB (confirms the firewall accepts Supabase egress); safe-modebookCleanupruns compared against what the Azure timer deletes. - Ops deploys the production worker (Stage 2).
⚠️ The checked-incloudflare/worker.jslists all migrated functions, so deploying it verbatim cuts everything over at once. For production, startSUPABASE_FUNCTIONSwith just"fs"and extend it one function at a time as each passes staging verification; each extension is an instant-rollback one-line change. Extendsociallast of all — its production cutover is deferred to the end-state DNS switch described in §3 (Phase 1), when thebloomlibrary.orghosts point directly at Supabase and the worker is retired. - Timers: uncomment the bookCleanup workflow schedule only after step 3's safe-mode comparison and after disabling the Azure timer (never both live). Disable the Azure contentfulToCrowdin timer once the GitHub cron has succeeded a few days in a row.
- Phase 6 (materialized views): set up pg_cron + the
refresh_logchange + the freshness watchdog in the analytics DB (see Phase 6 above) and disable the AzuredailyTimer. - Provision production secrets and repeat the per-function worker extension on
api.bloomlibrary.org(step 4's list and ordering —sociallast). - Decommission Azure per the checklist above.
Note on tests: the suite includes live tests ported from Azure (Google Sheet lookups, prod-Parse
OPDS queries, S3 bucket round-trips, the end-to-end bookCleanup scenario). They skip themselves
unless their credentials are present in the environment — same env-var names as the functions
(see .env.example) — so a bare pnpm test:ci run only exercises what its secrets allow.
Confirmed from the Cloudflare dashboard + live traffic. Updated 2026-07-10, when the Workers replaced the 302 Redirect Rules (Phase 0.5).
| Hostname / path | Mechanism | Destination | Notes |
|---|---|---|---|
api.bloomlibrary.org/v1/* |
Worker bloom-api-router — server-side proxy |
fs → sekpsuviwfhzzznzrdgx.supabase.co (prod Supabase); everything else falls through to the zone origin (Azure) |
path rewritten /v1/<fn> → /functions/v1/<fn> in cloudflare/worker.js |
staging-api.bloomlibrary.org/v1/* |
Worker bloom-api-router-staging — proxy |
fs → mwatyhkxsprxgnkcbalq.supabase.co (staging Supabase); everything else → Azure via ORIGIN_HOST |
same script as production |
(disabled) Redirect Rules supabase edge functions / ... - staging |
302 on /v1/fs/* |
prod / staging Supabase | disabled 2026-07-10; kept in the dashboard for instant rollback |
api.bloomlibrary.org/* (rest) |
proxied DNS record | bloom-functions.azurewebsites.net (Azure Functions app) |
Azure Request-Context header visible in responses; the Worker's fall-through relies on this record |
social.bloomlibrary.org/* |
not yet inspected | Azure social function |
needs updating in Phase 1 |