Routing
A fresh Ounce project has no src/pages/ and no src/middleware.ts, and does
not need either. The integration injects every route and adds its own
middleware. This page covers what that gives you and how to work alongside it.
Injected routes
Section titled “Injected routes”Nineteen admin routes, three public ones, and the MCP endpoint.
| Pattern | Does |
|---|---|
/[...path] |
Renders a page through your page component |
/404 |
Renders the not-found view on its own |
/media/[...key] |
Streams an R2 object |
/admin, /admin/pages, /admin/settings, and sixteen more |
The admin |
/_ounce/mcp |
The MCP server. Always mounted, answers only once a plugin supplies an MCP provider |
The catch-all does four things in order. It loads the page for the requested
path. If there is no page, it looks for a redirect left behind by a rename and
sends a 301. Failing that, it loads the not-found view and answers 404. On a
hit it sets Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400, so Cloudflare serves the page from cache for ten
minutes and keeps serving a stale copy for a day while it refreshes.
It refuses admin paths outright, with a bare 404 rather than your not-found
view. A rest parameter sorts last in Astro, so /admin/anything-unmatched would
otherwise reach the catch-all and be answered by the public site, complete with
its analytics beacon and a canonical naming the admin URL.
Saving in the admin purges the affected paths, but only when SITE_ORIGIN,
CF_PURGE_ZONE_ID and CF_PURGE_API_TOKEN are all set. Miss one and the purge
is skipped in silence, which looks exactly like a save that did not work.
/media/[...key] answers with Cache-Control: public, max-age=31536000, immutable and the object’s own ETag. Ounce writes content-addressed keys, so a
replaced image is a new key and the year-long cache never lies.
Your own pages
Section titled “Your own pages”Create src/pages/ and put files in it. Astro ranks a file route above an
injected one, so your file wins.
That cuts both ways. A file at src/pages/privacy.astro shadows the CMS page
whose slug is /privacy, completely and with no warning. The editor keeps
editing the page in the admin, the preview keeps showing the changes, and the
public site keeps serving your file.
Middleware
Section titled “Middleware”The integration registers it with order: 'pre', so it runs before yours. In
order, it:
- Redirects an apex host to its
www.canonical, whenSITE_ORIGINnames one. - Calls
authenticate()and setsAstro.locals.editor. - Answers 403 to any
/adminpath when there is no editor. - Runs the rest of your app.
- Sets the security headers.
- Marks
/adminand/_ounceresponsesprivate, no-storeandnoindex.
Write your own src/middleware.ts if you need one. It runs after Ounce’s and
sees locals.editor already populated.
Posting to the admin from a script
Section titled “Posting to the admin from a script”Astro’s own security.checkOrigin defaults to true for output: 'server',
and every admin save is a form POST. A request without a same-origin Origin
header is refused before any Ounce code runs, with a bare 403 and no body.
This never shows up in a browser, which sets the header itself. It shows up the first time you script a save:
curl -X POST http://localhost:4321/admin/pages/1 \ -H 'Origin: http://localhost:4321' \ --data 'heading=Hello'Ounce has no origin check of its own, so there is nothing to configure here. The 403 is Astro’s and the header is the fix.
Canonical redirect
Section titled “Canonical redirect”It fires only when SITE_ORIGIN parses and its hostname starts with www..
With SITE_ORIGIN unset, as in local dev, there is no redirect and no
X-Robots-Tag.
That last part matters on staging. Point a staging environment’s SITE_ORIGIN
at production and every staging response claims a canonical host it does not
have, so it answers noindex correctly but a save there purges production’s
cache.
Admin path matching
Section titled “Admin path matching”Guarding on pathname.startsWith('/admin') is not enough on its own.
/%61dmin, //admin and /ADMIN all reach the same routes. Ounce decodes up
to four times, collapses repeated slashes and lowercases before comparing, so
those all count as admin paths and get the 403.
Content-Security-Policy
Section titled “Content-Security-Policy”Ounce builds two policies and sends one of them, but only on a production
build. import.meta.env.PROD is false in astro dev, so a missing host does
not show up until you deploy. Check it against the built worker, where the flag
is true.
The adapter writes its own Wrangler config during the build, so that is the one to run, and it needs to be pointed at the same local D1 the dev server has been writing to:
astro buildwrangler dev -c dist/server/wrangler.json --persist-to "$PWD/.wrangler/state"curl -sI http://localhost:8787/ | grep -i content-security-policyBoth flags matter. Without -c you are running whatever wrangler.jsonc points
at, which before the first build is nothing. Without --persist-to the worker
gets a fresh empty database and every page answers 500 with no such table: pages, because the paths inside dist/server/wrangler.json resolve relative to
dist/server.
.dev.vars resolves from there too, so the copy in your project root is not
read. Copy it across if the built worker needs it. Signing in is not what it is
for: the dev editor is gated on import.meta.env.DEV, which a build sets false,
so /admin answers 403 here whatever you put in the file. Check the admin
against astro dev and the CSP against this.
Stop the dev server first, or start it again afterwards. A build and a dev server no longer fight over one Vite cache directory, because the integration gives each command its own, but only one process can hold the local D1 file.
The public policy combines three sources. Ounce’s own directives, every host
your installed plugins declare, and the csp block in your config:
export default defineSite({ csp: { img: ['https://api.mapbox.com'], frame: ['https://booking.example.com'], },});Keys are script, style, img, font, connect and frame. Each starts at
'self' and your hosts are appended. There is no way to remove 'self'.
A plugin contributes through the same six keys, its own hosts, so installing
one that needs an origin does not make its hosts your problem. An analytics
provider can also declare script, connect and img for its beacon. Both
land in the same directives, deduplicated.
Values are emitted as written, so a 'sha256-...' goes through as readily as
an origin. That is how a plugin ships an inline script without
'unsafe-inline', which is never added.
Four directives are fixed and take no configuration: frame-ancestors 'none',
base-uri 'none', object-src 'none' and form-action 'self'. The last one is
'self' rather than 'none' because the admin posts its forms back to the same
origin, and 'none' would break every save.
The admin gets a tighter policy with no external hosts at all, except the one your logo service uses if you configured one.
Other headers
Section titled “Other headers”Every response carries X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: DENY and a
Permissions-Policy that turns off camera, microphone, geolocation and payment.
Production adds Strict-Transport-Security: max-age=31536000; includeSubDomains.
X-Frame-Options: DENY goes on every response, the admin’s included, so no page
on the site can be embedded in a frame anywhere. Your own tooling is not an
exception.
The admin preview looks like a counter-example and is not. It never navigates.
preview.js fetches /admin/preview/[id] with fetch() and assigns the HTML
to frame.srcdoc, and a frame that is never pointed at a URL never triggers the
header. Anyone tempted to simplify that code into frame.src = endpoint will
break the preview instantly, and the browser will blame a header rather than the
edit.
frame-src governs the frames your own templates embed, a booking widget or a
map. It starts at 'self' because that is what the browser falls back to when
the directive is absent, so listing a host adds to it rather than replacing it.