One Origin, Two Runtimes: The Admin Dashboard
An Astro site and a Hono Worker sharing a single hostname — how the routes are split, why the session cookie is host-only, and the bug that taught me the difference.

Update, September 2026: Payload CMS now does this job, and the dashboard below lives on as an exhibit at admin-v1.rba.dev — View as guest to click through it. It runs on its own hostname rather than under
rba.dev/admin, so the routes below are shown as they are deployed there. The Images panel and the Cloudinary usage widget work too, against the original account, though the site itself now serves its photos from R2.
The previous part ended with a signed session cookie and no UI to use it on. This one is about the UI, and about a decision that looks like a deployment detail and turns out to be the architecture: the dashboard and the API live on the same hostname.
Two runtimes, one origin
admin-v1.rba.dev is served by an Astro worker. admin-v1.rba.dev/admin/api/* is served by a
completely different Hono worker. Same host, same certificate, same cookie jar — different code,
deployed independently. (When this was the real admin, the host was rba.dev itself and there
was a third pattern for the Cloudinary webhook. The webhook is gone; the shape is the same.)
Cloudflare makes that a three-line configuration:
[[env.production.routes]]
pattern = "admin-v1.rba.dev/admin/api/*"
zone_name = "rba.dev"
[[env.production.routes]]
pattern = "admin-v1.rba.dev/auth/*"
zone_name = "rba.dev"Two patterns. Everything matching them goes to the admin worker; everything else falls through to Astro. The Worker never learns about the dashboard's routes and the dashboard never learns about the Worker's.
The alternative — admin.rba.dev calling api.rba.dev — is what I'd have built by reflex,
and it would have dragged in CORS preflights, SameSite=None, a cookie domain wide enough to
span both, and a set of problems that only exist because I put a dot in the wrong place.
The dot in the wrong place
I did put the dot in the wrong place, for four months.
The session cookie was originally scoped to .rba.dev — leading dot, meaning every subdomain.
It works. It is also wrong, and the way it is wrong took a while to see, because the symptom
is not "login broken". The symptom is "I logged into test and now production says I'm someone
else."
A .rba.dev cookie is one cookie shared by every environment. Log into test.rba.dev and the
browser cheerfully sends that session to rba.dev too. Two environments, one slot, last write
wins.
Host-only cookies fix it by doing less:
// Both auth cookies are host-only on purpose: the worker and the Astro site share
// a hostname per environment. A `domain: '.rba.dev'` cookie is visible to every
// environment at once, so logins on dev/test/prod would clobber each other's state.No domain attribute at all. The cookie belongs to the exact host that set it. And because
the Worker and the Astro site are the same host, nothing is lost — which is the payoff for
the routing decision above. Sharing an origin is what makes the narrow cookie sufficient.
Verifying the same cookie in two places
The Astro pages are server-rendered and need to know who you are before they render anything. So they verify the session themselves:
const session = await resolveAdminSession(Astro.cookies)
if (!session) {
return Astro.redirect('/admin/login')
}
const { user, readOnly, adminWorkerUrl } = sessionWhich means the HMAC verification exists twice — once in the Worker, once in the Astro app. There is no shared package between them. I wrote the duplication down rather than pretending it wasn't there:
// KEEP IN SYNC with apps/admin-v1-api/src/auth.ts (verifySessionCookie).
// Same HMAC session-cookie verification, minus the worker's Env plumbing.
// No shared package exists between the two apps, so the logic is duplicated —
// any change to the token format must land in both files in the same deploy.I'm not going to defend this as elegant. It's about sixty lines of base64url and
crypto.subtle copied between two apps in the same monorepo, and a shared library is the
obvious fix. What it buys today is that neither app can break the other's build, and the
version check from part one is what stops a half-deployed change from becoming a privilege
bug: bump SESSION_VERSION in both, and any cookie minted by the old shape is rejected rather
than misread.
Two gates, not one
Authentication and authorisation are separate middlewares, and only one of them is global.
requireAuth is the baseline on every /admin/* route. It also does something slightly
unusual — it accepts an API key as an alternative principal:
const apiKey = c.req.header('X-API-Key')
if (apiKey && c.env.SCRIPT_API_KEY && apiKey === c.env.SCRIPT_API_KEY) {
c.set('user', { email: 'script@rba.dev', name: 'CLI script', sub: 'script', role: 'admin' })
await next()
return
}The maintenance scripts have no browser and no way to complete an OAuth dance. Rather than
bolt a headless login onto them, they present a key and become an explicit, named principal.
It shows up in logs as script@rba.dev, which is worth more than it sounds when you're
reading back through what touched the database.
requireAdmin is the second gate, and it is deliberately not global:
// requireAdmin — admin role only.
// Apply this on top of requireAuth for write operations.
// Use it inline on individual routes, not as a global middleware:
//
// app.post('/admin/api/locations', requireAdmin, handler)
// app.delete('/admin/api/locations/:id', requireAdmin, handler)Global write-protection sounds safer and is worse. It has to guess which requests are writes —
usually by method — and the moment there's a POST /admin/api/search, the guess is wrong in
the annoying direction, or a write sneaks in under a verb the rule doesn't cover. Naming the
gate on each mutating route means the protection is visible at the place it applies, and
adding a route without thinking about it fails closed on review rather than silently at
runtime.
The UI
Astro pages, React islands. The pages do the data fetching on the server:
const [locRes, imgRes] = await Promise.all([
fetch(`${adminWorkerUrl}/admin/api/locations`, {
headers: { Cookie: `session=${sessionCookie}` },
}),
fetch(`${adminWorkerUrl}/admin/api/images`, {
headers: { Cookie: `session=${sessionCookie}` },
}),
])...and the interactive parts — the images manager, the locations table, the map picker — hydrate as islands:
<AdminLayout title="Images" user={user} workerUrl={adminWorkerUrl} readOnly={readOnly}>
<ImagesManager client:load workerUrl={adminWorkerUrl} readOnly={readOnly} />
</AdminLayout>readOnly flows from the role all the way down. A reader gets the whole dashboard, every
panel, and no buttons that change anything — which is what View as guest on the exhibit
signs you in as. Try a write from there and the API answers 403. That is the tier I actually wanted
when I wrote the allowlist in part one: not a locked door, a viewing gallery.
Two of the panels talk to Cloudinary through the API worker: the images manager and a small usage widget that shows storage, bandwidth and transformation counts against the plan's limits. Both still work on the exhibit against the original account. The site has since moved its photos to R2, so that account is no longer read by anything live. The locations panel reads and writes its own database.
The public site ships almost no JavaScript. The admin ships a real React app. Same framework, same repo, same deploy — the island boundary is where they part company, and nothing about the blog pays for the dashboard's bundle.
What I'd keep
The single origin. It removed an entire category of problem — CORS, cookie scope,
SameSite — by making the question not apply, and every fix that came later was easier
because of it.
The duplication is the part I'd revisit first, and the part that most needs the comment above it to stay true.
Next: the Cloudflare side. Environments, custom domains, and the several distinct ways I have managed to take a hostname offline.