Fix round 2 for Task 14, addressing a critical review finding reproduced on
a real built server.
C1 (critical): the generated production entry set the authz catalog inside
createProductionServer's BODY, but ES modules evaluate every static import
(including app middleware, emitted as a static import) before the importing
module's body runs. Middleware reading getAuthzCatalog() at module scope —
the same eager shape authzMiddleware({ catalog, ... }) itself requires, and
the pattern app/middleware/logger.ts's `export default requestLogger({...})`
already uses — saw an unset catalog and crashed the whole process at import
time, after every other gate (typecheck/lint/tests/a plain `bun run build`)
stayed green.
Fix: packages/cli/src/build.ts now emits a small side-effecting
`.authz-setup.ts` module containing the static imports of every
app/authz/*.ts declaration plus a call to the new
applyAuthzManifestEarly(entries) (packages/dev-server/src/prod.ts), and
imports THAT MODULE FIRST in the generated entry — before pages, api,
realtime, middleware, components, and layouts. applyAuthzManifestEarly is
deliberately silent (no missing-default-export warnings, though a genuine
conflict still throws and fails the boot at import time); createProductionHandlers
keeps its own unconditional merge+set as an idempotent, always-warning second
pass, so an adapter that bypasses the generated entry and calls it directly
still gets a correctly merged, validated catalog, and so the function stays
independently testable.
I3: corrected packages/authz/src/client.ts's WRN-AUTHZ-SETUP message, which
claimed prod always sets the catalog before middleware runs — true again for
the generated entry after the C1 fix, but not for a custom entry that calls
createProductionHandlers directly.
I2: dev HMR editing app/authz/*.ts reloaded the page while the OLD catalog
stayed authoritative (watch.ts classifies any non-CSS change as "server";
hotUpdate had no authz/ branch) — a false security signal, since tightening
or removing a permission looked like it took effect but didn't until a
restart. Added the branch (packages/dev-server/src/index.ts), and gave
loadAppAuthzCatalog (authz-boot.ts) an injectable importer: a raw import()
would have silently no-op'd on the re-import (Bun caches local TS/JS modules
by filesystem path and ignores query strings), so the hot path routes through
loadModule (pipeline.ts) instead, which copies the edited file to a versioned
sibling specifically to defeat that cache.
I4: added direct createProductionHandlers/applyAuthzManifestEarly tests
(packages/dev-server/test/authz-prod.test.ts: conflict throws naming both
files, missing default export warns and skips, empty array yields an empty
catalog, a second call re-validates rather than trusting a stale singleton)
and the regression test that matters most
(packages/cli/test/authz-prod-coldstart.test.ts): a real `runBuild` + a real
`bun dist/server.js` boot, with a middleware module reading
getAuthzCatalog() at module scope, asserting it actually serves a request.
M5: startServer built its own router once, then loadAppAuthzCatalog built a
second one from scratch on every dev boot and every authz/ hot reload.
loadAppAuthzCatalog now accepts either an appDir (still used standalone, e.g.
by the test suite) or an already-built Router, and both call sites in
index.ts now pass the router they already have.
Every fix in this round was verified non-vacuous by sabotaging it and
confirming the corresponding test fails, then reverting.
@wrnexus/authz
Composable authorization for WrNexus — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an
authorize()guard.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/authz is a small, server-side authorization toolkit. It gives you three
interchangeable models — RBAC (roles → permissions), PBAC (policy predicates), and
ABAC (attribute matchers) — that all collapse to a boolean | Promise<boolean> decision.
Wrap any decision in a Middleware guard (authorize, requireRole, requirePermission)
to protect WrNexus routes. Reach for it whenever a route or action needs to be gated on who
the user is, what roles they hold, or attributes of the user and the resource. It plugs into
@wrnexus/core by reading ctx.user as the authorization subject.
Installation
bun add @wrnexus/authz
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
The package has a single entry point (@wrnexus/authz) exporting the following.
Types
| Symbol | Description |
|---|---|
Subject |
The authorized principal: { id?: string; roles?: string[]; [attribute: string]: unknown }. |
Rbac |
An RBAC checker: { can(subject, permission): boolean; permissionsFor(roles): Set<string> }. |
Policy<S = Subject, R = unknown> |
A predicate (subject: S, resource?: R) => boolean | Promise<boolean>. |
RBAC
defineRbac(roles: Record<string, string[]>): Rbac
Builds an RBAC checker from a role → permissions map. Supported permission forms:
"*"— grants every permission."ns:*"— namespace wildcard (e.g."post:*"grants"post:write")."role:<name>"— inherits all permissions of another role (resolved recursively, cycle-safe).
The returned Rbac provides:
can(subject, permission)—trueif any ofsubject.rolesgrantspermission(honouring*and namespace wildcards). Returnsfalsewhen the subject has no roles.permissionsFor(roles)— the resolvedSet<string>of all permissions granted to a set of roles.
hasRole(subject: Subject | undefined, ...required: string[]): boolean
true if the subject holds all of the given roles.
PBAC / ABAC combinators
any<S, R>(...policies: Policy<S, R>[]): Policy<S, R>— allow if any policy passes (OR); awaits async policies.all<S, R>(...policies: Policy<S, R>[]): Policy<S, R>— allow only if all policies pass (AND); awaits async policies.attr<S extends Subject>(name: string, match: unknown | ((value: unknown) => boolean)): Policy<S>— ABAC helper that allows whensubject[name]equalsmatch, or whenmatchis a function, whenmatch(value)is truthy.
Guards (middleware)
Each guard returns a @wrnexus/core Middleware. A denied request short-circuits with
Response.json({ ok: false, error: "Forbidden" }, { status: 403 }).
authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware— runspolicyagainst the requestContext; callsnext()when it resolves truthy, otherwise returns 403.requireRole(...roles: string[]): Middleware— allows whenctx.userholds any of the listed roles.requirePermission(rbac: Rbac, permission: string): Middleware— allows whenrbac.can(ctx.user, permission)istrue.
Usage
RBAC
import { defineRbac, hasRole } from "@wrnexus/authz";
const rbac = defineRbac({
admin: ["*"],
editor: ["post:read", "post:write"],
viewer: ["post:read"],
// role inheritance: lead gets everything an editor has, plus post:publish
lead: ["role:editor", "post:publish"],
});
const user = { id: "u1", roles: ["editor"] };
rbac.can(user, "post:write"); // true
rbac.can(user, "post:delete"); // false
rbac.permissionsFor(["lead"]); // Set { "post:read", "post:write", "post:publish" }
hasRole(user, "editor"); // true
Guarding routes
import { authorize, requireRole, requirePermission, defineRbac } from "@wrnexus/authz";
const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
// Only admins or editors
app.get("/dashboard", requireRole("admin", "editor"), handler);
// Requires a specific permission
app.post("/posts", requirePermission(rbac, "post:write"), handler);
// Arbitrary policy over the request context
app.delete(
"/posts/:id",
authorize((ctx) => hasRole(ctx.user, "admin")),
handler,
);
PBAC / ABAC policies
import { any, all, attr, authorize, type Policy } from "@wrnexus/authz";
interface User {
id: string;
department?: string;
roles?: string[];
}
interface Post {
authorId: string;
}
// Ownership policy (subject + resource)
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
// ABAC: attribute equality, or a predicate
const inEngineering = attr<User>("department", "engineering");
const isVerified = attr<User>("verified", (v) => v === true);
// Compose: allow if the user owns the post OR is in engineering AND verified
const canEdit = any(ownsPost, all(inEngineering, isVerified));
app.put(
"/posts/:id",
authorize((ctx) => canEdit(ctx.user as User, loadPost(ctx))),
handler,
);
Requirements / Notes
- Bun-only — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
- Works with
@wrnexus/core— the guards returnMiddlewareand read the subject fromctx.useron the requestContext. Both types are imported from@wrnexus/core. - Policy combinators (
any,all) andauthorizeare async-aware, so policies may return aPromise<boolean>(e.g. for a database ownership check).