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.

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

rba.dev is served by an Astro worker. rba.dev/admin/api/* is served by a completely different Hono worker. Same host, same certificate, same cookie jar — different code, deployed independently.

Cloudflare makes that a three-line configuration:

[[env.production.routes]]
pattern = "rba.dev/admin/api/*"
zone_name = "rba.dev"

[[env.production.routes]]
pattern = "rba.dev/auth/*"
zone_name = "rba.dev"

[[env.production.routes]]
pattern = "rba.dev/webhook/cloudinary"
zone_name = "rba.dev"

Three 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.

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 } = session

Which 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/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, every photo, and no buttons that change anything. That is the tier I actually wanted when I wrote the allowlist in part one: not a locked door, a viewing gallery.

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.