Skip to content

The platform seam ​

The hosted instance of Spelling Creator runs on Cloudflare, and will keep doing so. But almost nothing in apps/api needs to: the lesson hub talks to Postgres over PostgREST, identity comes from GoTrue, and both are ordinary HTTP APIs with open-source servers behind them.

What was Cloudflare-shaped is where everything else lives: lesson images and packed lesson histories in R2, rate-limit buckets in KV, cached renders in caches.default. Those three had no standard equivalent, and route code called their bindings directly.

The platform seam (apps/api/src/platform/) is the layer that fixed that. It names the handful of operations this API actually performs against each of them, so a handler can ask for "the image store" rather than for env.IMAGES.

What it covers ​

text
platform(env) -> {
  images,      BlobStore | null   lesson images, keyed by content hash
  lessonGit,   BlobStore | null   lesson history packfiles
  rateLimit,   KvStore   | null   rate-limit buckets + the AI answer cache
  oauthState,  KvStore   | null   short-lived MCP OAuth authorization state
  cache,       ResponseCache      HTTP response cache (never null; may no-op)
  clientIp(request) -> string
}

Handlers reach for these through the named helpers in platform/index.js (imageStore, gitStore, rateLimitStore, oauthStateStore, responseCache and clientIp) rather than the object:

js
import {
  imageStore,
  rateLimitStore,
  responseCache,
} from "../platform/index.js";

const images = imageStore(env);
if (!images) return textResponse("Image store is not configured.", 500, cors);
const object = await images.get(hash);

A store is null when its backing service isn't configured, which is how a preview deployment without a bucket still serves every route that doesn't need one. cache is the exception and is never null, because "don't cache" is always a valid answer; a host with nowhere to cache returns the no-op cache (noopCache in platform/cache.js).

The three interfaces ​

Each is deliberately tiny, and each is the intersection of what the hosted services and their self-hostable counterparts both offer. The interface definitions, with what each method must do, are in platform/blobs.js, platform/kv.js and platform/cache.js.

BlobStore: head, get, put, delete, list. No multipart, no conditional writes, no presigning, no ACLs. Both stores here are content-addressed or author-owned and written whole. The shapes are normalized away from R2's vocabulary: route code sees contentType, etag and metadata, never httpMetadata, httpEtag or customMetadata. delete takes one key or an array, and list returns a page of objects with their metadata plus a cursor and a truncated flag.

KvStore: get, put, delete, string values, one expirationTtl option. Deliberately no list, no atomic increment, no compare-and-set: the rate limiters are read-modify-write token buckets that tolerate a lost update, and nothing else mutates a shared key, so an adapter never needs a transaction.

ResponseCache: match, put, delete, keyed by URL string. Cloudflare's Cache API keys by Request, which is a Workers shape; a string key is something any host can implement, including by not implementing it at all. Every method is best-effort: a cache that fails must change how much work a handler did, never what it returned. Its users are image bytes, crawler prerender snapshots and og-image screenshots.

The conformance suite ​

A seam is only worth having if the implementations behind it are genuinely interchangeable, so the contract is executable rather than documentary. src/platform/conformance.js exports testBlobStore and testKvStore; each adapter's test file runs them against a live instance of itself, and passing them is what "implements BlobStore" means.

src/platform/cloudflare.test.js runs both suites against real R2 and KV inside workerd (Miniflare provides a TEST_BLOBS bucket and a TEST_KV namespace for exactly this, configured in apps/api/vitest.workers.config.js). Those results are the reference: whatever the suite asserts there is what any other adapter has to reproduce. s3.test.js runs testBlobStore and postgrestKv.test.js runs testKvStore, both against in-process fakes (see below).

The cases it pins down are the ones that would otherwise diverge quietly: a missing key reading as null rather than throwing, metadata (including non-ASCII values) surviving a round trip, a key that needs escaping, a key with a . or .. segment being refused, delete([]) being a no-op rather than an error or a bulk wipe, and truncated and cursor agreeing on the last page of a listing.

The S3 blob store ​

src/platform/s3.js is a BlobStore over any S3-compatible object store: RustFS, MinIO, Garage, Ceph RGW, SeaweedFS, Backblaze B2, or AWS itself.

js
import { s3Blobs } from "./platform/s3.js";

const images = s3Blobs({
  endpoint: "http://storage:9000",
  bucket: "lesson-images",
  accessKeyId: process.env.S3_ACCESS_KEY_ID,
  secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
  region: "us-east-1", // what MinIO and Garage expect
});

It also takes sessionToken for temporary credentials and forcePathStyle (below).

It is built on fetch and a hand-written SigV4 signer (src/platform/sigv4.js) rather than on @aws-sdk/client-s3. The SDK is tens of megabytes and assumes Node; signing is a page of well-specified arithmetic that runs unchanged in workerd, in Node, and anywhere else with fetch and crypto.subtle. Only five operations are needed and none of them is complicated.

Signing is the kind of code that is either exactly right or quietly wrong for a subset of inputs: a key with a space in it, a query string in the wrong order. So sigv4.test.js checks it against AWS's own published examples, asserting the canonical request and the string-to-sign as well as the final signature, which narrows a mistake to one line instead of one digest.

Two places where S3 and R2 genuinely differ, both absorbed by the adapter:

  • Metadata is ASCII. x-amz-meta-* headers are ASCII by specification, where R2's customMetadata takes arbitrary strings. Values are percent-encoded on the way out and decoded on the way in, so the round trip is lossless without depending on a server tolerating bytes it was never promised.
  • Listings carry no content type. A ListObjectsV2 response has keys, sizes and ETags but not content types, where R2's listing has them. Rather than weaken the contract (the WEBP backfill in routes/admin.js filters on content type straight off the listing), the adapter pays with a HEAD per listed object, eight at a time. The backfill pages in batches of at most 50 and then reads and rewrites every object it didn't skip, so the extra request is small next to the work it saves. The only other caller, the dependency check, lists a single object.

Addressing defaults to path style (http://host:9000/bucket/key), which is what a self-hosted store serves without wildcard DNS. Pass forcePathStyle: false for AWS-style virtual hosts.

Keys are opaque, slashes included, with one exception. A key containing a . or .. path segment is refused by every adapter (assertUsableKey in platform/blobs.js), because a URL resolves those before the request is signed: git/../images/x would be stored as images/x on an S3 host and as itself on R2, and a key starting ../ would address a different bucket entirely. Nothing here produces such a key; refusing one is so that the day something does, it fails at the call rather than at the wrong object.

Bulk deletes are issued as parallel DELETEs, eight at a time, rather than through S3's DeleteObjects POST. That API is the one smaller S3 implementations most often lack, and building and signing an XML body would save nothing at the sizes this sees.

s3.test.js runs the shared conformance suite against an in-process S3 built over a Map. A stub rather than a container in CI, and a stub can be stricter than a real server: this one rejects an unsigned request, so every test also asserts that the request was signed, and it stores metadata as the raw header bytes it received, so the encoding round trip is exercised rather than assumed.

The Postgres key-value store ​

src/platform/postgrestKv.js is a KvStore over a (key, value, expires_at) table, reached through PostgREST.

js
import { postgrestKv } from "./platform/postgrestKv.js";

const kv = postgrestKv({
  url: process.env.SUPABASE_URL, // or any PostgREST server
  apiKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
});

Through PostgREST rather than through pg on purpose. Everything else this API reads or writes already goes over PostgREST with the service-role key, so this needs no new dependency, no connection pool and no second set of credentials, and it keeps working in the Workers runtime, which cannot open a raw TCP connection to Postgres at all. A direct-SQL adapter can be written later behind the same interface if the extra hop ever matters; for a store whose callers already accepted an eventually-consistent KV, it does not.

The table is kv_store, in apps/api/schema.sql. Creating it on the hosted instance too is harmless: nothing writes to it there. A put is an upsert (PostgREST's resolution=merge-duplicates), which is what lets the second write to a rate-limit bucket replace the first.

Expiry is enforced on read, not by the database. PostgREST offers no TTL, and depending on pg_cron being installed would make a self-host fail in a way that looks like a rate limiter that never resets. A row past its expiry reads as absent and is swept on the way past, so correctness never depends on the periodic sweep running; that only reclaims rows nobody asks for again. schema.sql carries the pg_cron snippet for instances that can run it. The sweep matches on the expiry it just read as well as the key, so a put racing the read can't have its fresh row deleted along with the stale one.

Nothing in the table is authoritative: every row is small, short-lived, and has an expiry past which its absence is the right answer. That is what lets it be an ordinary table with no locking and no transactions: the rate limiters are read-modify-write token buckets that tolerate a lost update, and the worst one costs is a single extra request served.

A failed read returns null rather than throwing, so an unreachable database degrades to a cache miss instead of taking the route down with it. A failed write does throw: silently not recording a rate-limit charge is not a degradation, it is a hole. A failed delete throws too, because a caller deleting a consumed OAuth authorization is entitled to know it is still there.

postgrestKv.test.js runs the conformance suite against a fake PostgREST injected through the adapter's fetch option.

Adding a host ​

Write one module that returns the shape above, and put it on env.PLATFORM before the first request. That is the whole switch: there is no registry and no detection, because a host always knows what it is. The Worker is the only case that can't say so, and it's the fallback (cloudflarePlatform in platform/cloudflare.js).

apps/api/src/node/platform.js is the worked example. Condensed:

js
// A non-Cloudflare entry point, once at startup:
const s3 = { endpoint, accessKeyId, secretAccessKey, region, forcePathStyle };
const kv = postgrestKv({
  url: env.SUPABASE_URL,
  apiKey: env.SUPABASE_SERVICE_ROLE_KEY,
});

env.PLATFORM = {
  images: s3Blobs({ ...s3, bucket: env.S3_BUCKET_IMAGES }),
  lessonGit: s3Blobs({ ...s3, bucket: env.S3_BUCKET_GIT }),
  rateLimit: kv,
  oauthState: kv,
  cache: noopCache,
  // NOT `headers.get("x-forwarded-for")`. See below.
  clientIp: clientIpFrom(env),
};

One table serves both KV roles there, as one KV namespace could have; they stay separate names in the seam so that a host that wants to split them can.

Two of those lines deserve more than a glance.

endpoint is a plaintext http:// URL in every example here because the store is a container on a private network. SigV4 signs the request but encrypts nothing, so the objects and the signature both cross the wire in the clear, which is fine over a Docker network and is not fine anywhere the traffic leaves the host. Use https:// for anything shared, and for real S3.

clientIp is the one method where the obvious implementation is a security bug. x-forwarded-for is a list each hop appends to, and the client writes what is at the front, so reading the header whole, or reading its first entry, lets any caller choose the address that bans and rate limits are keyed on. The trustworthy entry is the one your own nearest proxy added, counted from the right, and how many to count back is a property of the deployment rather than of the code. See clientIpFrom in apps/api/src/node/platform.js, which reads TRUSTED_PROXY_COUNT for exactly that, and returns no IP at all when it is 0: a process with nothing in front of it has no trustworthy source for one. The Cloudflare adapter has it easy: cf-connecting-ip is written by the edge and overwrites whatever the client sent.

Then run the conformance suites against each new adapter. cloudflare.js is the reference implementation and is a thin renaming layer by design: no fallbacks, no retries, no behavior that wasn't already in the route. Anything cleverer belongs in the route, where it can be tested once for every host rather than once per host.

What this is not ​

The seam covers storage, not compute. Durable Objects (live collaboration and the remote MCP session), Browser Rendering (crawler prerendering and og-image screenshots) and Workers AI are all still Cloudflare-specific and are not behind it. Those routes are registered by the Worker entry (src/index.js) rather than by the shared route table in src/app.js, so another host simply doesn't have them.

The rich-text sanitizer used to be on that list (it was built on HTMLRewriter) and is no longer: it parses with parse5, which runs in every runtime. That one was solved by removing the coupling rather than by abstracting over it, which is the better answer whenever it is available. See Rich text.

What it does do is concentrate the coupling: the part that was spread across every route is gone, and what remains sits in a few named places. That is what made a second entry point possible; see Self-hosting for the Node one, which is createApp() from src/app.js plus a platform built by src/node/platform.js.

Copyright © 2026 Spelling Creator.