Skip to Content
AuthorizationAstro SSR

Astro SSR

Not on npm yet

npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

apps/storefront in this repository is Baize’s shop front: an Astro app with output: 'server' and the standalone Node adapter, running no JavaScript in the browser. It adopts apps/shop-api’s published document, decides on it in middleware and in page frontmatter, and the fences citing it run under nx test storefront.

The concept. An .astro page’s frontmatter runs on the server for every HTTP method at that URL, so it is the loader and the action in one module.

What you get. One can call at the top of the file gates the render and the write.

Why you want it. Baize’s cart answers GET and POST at one URL, and a decision made for the render never runs for the POST.

How the library gets you there. parseMatrix adopts shop-api’s document once in middleware, and every page reads that one evaluator.

What apps/storefront puts in one module

An .astro page’s frontmatter runs on the server and is the loader and the action for that page in one module, so one decision above both gates serves the render path and the write path. A prerendered Astro build has neither gate, because it has no process at request time. apps/storefront runs three pieces:

  • middleware opening the per-request context,
  • shop-api’s document adopted once at module scope,
  • cart.astro reaching one order.create decision that its POST branch and its checkout button both read.

parseMatrix adopts the fetched document once per process, so a can call in a page’s frontmatter is a local function call over a frozen copy and no render waits on a network round trip.

Baize’s cart is one URL answering two methods. A shopper presses a checkout button and a script posts intent=checkout to the same path without ever loading the page, and both arrive at the same frontmatter.

Server output is what makes the page a boundary

A prerendered build runs a page’s frontmatter once, at build time, and serves the HTML it produced. No process exists at request time to resolve a subject against, and no handler exists for a form to post to. A server-rendered build runs the frontmatter on every request inside a Node process the operator controls, which is the trusted runtime this page is about.

apps/storefront/astro.config.mjs sets output: 'server' and the @astrojs/node adapter in standalone mode. Under a prerendered build the middleware below would run once at build time, against whoever built the site.

The middleware opens the request context

Astro middleware runs ahead of any page’s frontmatter, so it is where this app resolves who is asking and which document to ask against. readSubject reads the visitor’s cookie, ShopApi.forRequest is the typed client for shop-api, and policyAccess answers with the adopted evaluator:

export const onRequest = defineMiddleware(async (context, next) => { const subject = readSubject(context.cookies); context.locals.subject = subject; context.locals.api = ShopApi.forRequest(context.request, subject); context.locals.cart = readCart(context.cookies); context.locals.access = await policyAccess(context.locals.api); const response = await next(); // Echoed on the way out so the id is readable with `curl -I` as well as in // the page, which is what makes the hop observable without a browser. response.headers.set(CORRELATION_HEADER, context.locals.api.correlationId); return response; });

Every page then reads Astro.locals.subject and Astro.locals.access and decides for itself. The middleware resolves both once per request, so two blocks on one page cannot decide against two different identities.

policyAccess awaits a fetch only where this process holds no document or the revalidation window has closed. Every other request reads a document already validated, deep-cloned and deep-frozen, so a decision in a page’s frontmatter is a function call. Platforms carries the cache, the window and what a failed poll leaves behind.

The frontmatter is the loader and the action in one module

An .astro page answers every method at its URL out of the same frontmatter, so one decision at the top of the file serves the render path and the write path. A React Router route and an RSC route each reach two entry points independently and each owes two decisions written in two places.

apps/storefront’s cart is that shape. decide answers with a Decision, or with undefined where this app holds no document to decide on, and mayOrder is what both gates read:

const decision = decide(access, subject, 'order', 'create'); const mayOrder = decision?.allowed === true;

The POST branch reads mayOrder before anything else. A refused visitor gets status 403 and the wording refusalText picks, and the cart cookie is untouched. Every intent the form carries is decided against order.create, including the three that only edit that cookie, because a cart is the draft of an order and a visitor the matrix refuses an order has nothing to draft.

The checkout button reads the same mayOrder further down the file:

<form method="post" action="/cart"> <input type="hidden" name="intent" value="checkout" /> <button class="baize-button baize-button--variant-primary" disabled={!mayOrder} > {mayOrder ? 'Place the order' : 'Sign in to order'} </button> </form>

The disabled button is the convenience and the POST branch is the enforcement. A script posting intent=checkout to /cart draws no button and still meets the decision above it. One can call answers both, so this page cannot show a control its own handler would refuse.

One decision at the top of cart.astro, read by both gates. A page view and a direct POST enter the same frontmatter, which resolves order.create once: the render path draws the checkout button enabled or disabled from that verdict, and the POST path answers 403 from the same one.

Narrow the roles before forwarding them

apps/storefront states its subject to apps/shop-api on the X-Shop-Subject header, so whatever it forwards is what that service believes about the caller. The cookie it reads is unsigned, which makes this hop the last place that knows which roles this surface may assert. It screens the claim down to the one role /session issues:

const : readonly string[] = ['customer'];

parseSubject discards a payload claiming any role outside that list, the whole payload and never part of it, so a hand-edited cookie claiming manager reaches the API as an anonymous shopper.

This app has no browser copy to keep honest

apps/storefront ships no JavaScript to the browser, so nothing there evaluates anything and every decision it makes happens in the Node process. That does not make its decisions the authoritative ones for the catalogue. apps/shop-api owns the data, holds the same document, and decides again on every request it receives, against a subject it resolves itself. Neither layer reads the other’s verdict, and a storefront that hid the checkout button does not excuse the service the form posts to.

Where to go next

Last updated on