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.astroreaching oneorder.createdecision that itsPOSTbranch 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.
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
- Security contract — subject authenticity, complete mediation, and the rest of what a consumer owns
- React Server Components — the same trusted render, with the write on a second entry point
- A policy from another service — closed mode and what a fetched document obliges