Subdomains and permissions
How every gift gets its own address on a host that doesn't support wildcard domains, and how Postgres grants keep payment state out of the browser's reach.
Forevermore is one pnpm monorepo:
apps/
landing/ marketing site
dashboard/ the editor, where gifts are built and paid for
experience/ the viewer that shows a finished gift
atlas/ internal asset browser
builder/ experimental, not deployed
packages/
templates/ the world catalog and the data contract every world reads
template-kits/ a library of reusable procedural assets
The split follows who's looking at the screen. The editor is a full app, with sign-in, uploads and checkout. The viewer is the opposite: a small, fast page that someone opens from a link on their phone, often without knowing what to expect. Keeping them as separate apps means the viewer doesn't carry any of the editor's weight, and a change to the editor can't break a gift that someone is opening right now.
This post covers the two architecture problems that shaped the rest of the system.
Every gift gets its own address
A finished gift lives on its own subdomain, like theirname.getforevermore.co. It's a small product decision with big consequences. It reads like a real place instead of a link with an ID on the end, and it's what people actually see in a text message.
To serve that, you need a wildcard domain: one setup that answers for *.getforevermore.co, whatever comes before the dot. That single requirement is most of the reason the stack runs on Cloudflare rather than Vercel. On Cloudflare, a wildcard on your own domain is covered by the free certificate it already issues. On Vercel, the same setup needed a paid plan plus certificate automation for each host. Cloudflare also doesn't charge for bandwidth, which matters for a product whose whole point is being shared.
To be fair to Vercel, its developer experience is nicer, and it runs native Node dependencies without complaint. That's exactly why the editor was the hardest app to move. It used sharp, an image library built on a native binary, which can't run on Cloudflare Workers, and it had to be replaced with Cloudflare's own image service before the editor could run there at all. Every platform choice sends you a bill like that somewhere. It's better to know where it is up front.
Routing a wildcard by hand
There was one more catch. Cloudflare Pages, where the apps are hosted, doesn't allow a wildcard as a custom domain. Cloudflare Workers routes do. A Worker is a small function that runs on Cloudflare's network in front of your site, and it can rewrite a request before passing it along.
So a Worker sits on *.getforevermore.co/* and does the routing itself. Simplified, it looks like this:
const RESERVED = new Set(['www', 'app', 'api', 'assets' /* … */])
export default {
async fetch(request) {
const url = new URL(request.url)
const label = url.hostname.split('.')[0] // "theirname"
if (label === 'www') {
url.hostname = 'getforevermore.co'
return Response.redirect(url.toString(), 301)
}
if (RESERVED.has(label)) {
return fetch(request) // infrastructure subdomains go to their own apps
}
// Anything else is a gift: send it to the viewer app,
// and remember which subdomain was asked for.
const originalHost = url.hostname
url.hostname = VIEWER_HOST
const proxied = new Request(url, request)
proxied.headers.set('X-Forwarded-Host', originalHost)
return fetch(proxied)
},
}
The viewer reads X-Forwarded-Host to work out which gift to show. That header is the key detail. Once a request has been proxied, the viewer sees its own hostname, not the one the recipient typed. Passing the original along in a header is the standard way proxies deal with this, and it's why X-Forwarded-Host and X-Forwarded-For show up all over web infrastructure.
It's a few dozen lines, and the whole product depends on them. The same reserved list lives in the shared package too, so nobody can give their gift a name like app or api.
The browser doesn't get to decide what's paid
Forevermore uses Supabase, which means the browser talks to Postgres more or less directly for anything the user is allowed to change. The editor saves a gift's title, photos and letter by updating the row itself, without a custom API endpoint for every field. That's fast to build with, but it moves the security boundary. If the browser can write to the database, the database's own permissions are the only thing standing between a user and any column they can name.
Postgres gives you two layers for this, and it helps to keep them separate in your head:
- Row Level Security (RLS) decides which rows a user can touch. For gifts, the policy boils down to "only your own".
- Column privileges decide which columns in those rows a user can write.
A lot of Supabase tutorials stop at RLS. But RLS alone would still let someone update the status of their own gift, and status is the column that says whether a gift has been paid for and published. So the signed-in role is granted write access to a specific list of columns and nothing else. In spirit, it looks like this:
-- Start from nothing...
revoke insert, update on public.projects from authenticated;
-- ...then allow only what the editor needs.
grant update (title, recipient_name, dedication, metadata, template_slug)
on public.projects to authenticated;
-- No `status`, no publish timestamps.
-- Only server code using the service role can change those.
The real list is longer, but the principle is the same. The first line matters more than it looks. In Postgres, a table-level UPDATE grant covers every column, and revoking one column afterwards doesn't carve it out. Revoking at the table level and granting back an explicit list is the reliable way to do it, and it has a nice side effect: a column added later is locked by default instead of open by default.
Publishing happens on the server, after payment
So who does change a gift's status? The payment webhook. When Paddle confirms a charge, it calls an endpoint on the server, which verifies the request and publishes the gift using the service role. The browser never sends a "mark as paid" request, because there's nothing it could send that the database would accept.
That also covers a case that's easy to forget: someone pays, then closes the tab before the success page loads. If publishing depended on the browser reaching that page, they'd have paid for a gift that's stuck in the editor. Because the webhook goes from Paddle's server to ours, the gift gets published whether or not the browser is still around.
Why not just validate harder in the frontend?
Frontend validation is guidance for honest users, not protection. Anyone can open dev tools and send whatever request they like. Locking down what the database accepts means a bug in the UI, or a curious user, can't publish something that wasn't paid for. And when a grant is wrong, you find out through a loud permission error, not a quiet data problem three weeks later.