fix(authz): fix prod boot-order (C1), dev HMR staleness (I2), add prod coverage (I4)
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.
This commit is contained in:
@@ -2,15 +2,32 @@
|
||||
* A process-wide authorization catalog registry, mirroring `@wrnexus/db`'s
|
||||
* `client.ts` (`setDb`/`getDb`/`hasDb`). It exists for the same reason: app
|
||||
* middleware runs at module-eval time — `app/middleware/*.ts` registers
|
||||
* `authzMiddleware({ catalog, store, ... })` itself, and it needs the merged
|
||||
* catalog *then*, before the first request. Passing it through `ctx` does not
|
||||
* work at that point, so the framework loads and merges every `app/authz/*.ts`
|
||||
* declaration at boot (dev: `loadAppAuthzCatalog` + `setAuthzCatalog`, before
|
||||
* middleware is resolved; prod: `mergeCatalogs` over the statically-imported
|
||||
* declarations + `setAuthzCatalog`, before the server starts listening) and
|
||||
* stashes it here. The framework never installs `authzMiddleware` itself — the
|
||||
* app always chooses its own store and registers the middleware; this registry
|
||||
* only makes the merged catalog reachable when it does.
|
||||
* `authzMiddleware({ catalog, store, ... })` itself, an EAGER call (the same
|
||||
* shape as `logger.ts`'s `export default requestLogger({...})`), and it needs
|
||||
* the merged catalog *then*, before its own module body finishes running.
|
||||
* Passing it through `ctx` does not work at that point, so the framework
|
||||
* loads and merges every `app/authz/*.ts` declaration and stashes it here
|
||||
* before any other module can observe it:
|
||||
*
|
||||
* - dev: `startServer` calls `loadAppAuthzCatalog` + `setAuthzCatalog`
|
||||
* before middleware is resolved.
|
||||
* - prod (the normal `wrnexus build` output): the generated entry statically
|
||||
* imports a small `.authz-setup.ts` module FIRST — before any page, API,
|
||||
* or middleware import — which calls `setAuthzCatalog` at ITS OWN module
|
||||
* scope. ES modules evaluate every static import before the importing
|
||||
* module's body runs, and evaluate sibling imports in declaration order,
|
||||
* so import position is evaluation order: this guarantees the catalog
|
||||
* exists before app middleware's own module body (which may read it
|
||||
* eagerly) ever evaluates. `createProductionHandlers` (`prod.ts`) then
|
||||
* repeats the merge as an idempotent second pass, mainly so a caller who
|
||||
* bypasses the generated entry and invokes it directly still gets a
|
||||
* catalog — for THAT path specifically, an eager module-scope read in
|
||||
* middleware is only safe if the caller sets the catalog before importing
|
||||
* the middleware itself, since no generated `.authz-setup.ts` runs first.
|
||||
*
|
||||
* The framework never installs `authzMiddleware` itself — the app always
|
||||
* chooses its own store and registers the middleware; this registry only
|
||||
* makes the merged catalog reachable when it does.
|
||||
*/
|
||||
|
||||
import type { AuthzCatalog } from "./types.ts";
|
||||
@@ -28,11 +45,13 @@ export function getAuthzCatalog(): AuthzCatalog {
|
||||
if (!catalog) {
|
||||
throw new Error(
|
||||
"WRN-AUTHZ-SETUP: no authorization catalog is configured. The dev server and " +
|
||||
"production build call loadAppAuthzCatalog()/mergeCatalogs() and setAuthzCatalog() " +
|
||||
"automatically before your app's middleware runs. If you're seeing this, either " +
|
||||
"getAuthzCatalog() ran before that boot step (e.g. at import time) or you're " +
|
||||
"outside the normal boot path (a standalone script or test) and must call " +
|
||||
"setAuthzCatalog(catalog) yourself first.",
|
||||
"`wrnexus build`'s generated production entry both call setAuthzCatalog() before " +
|
||||
"any other module — including your app's middleware — evaluates. If you're seeing " +
|
||||
"this: (a) you're on a custom production entry that calls createProductionHandlers " +
|
||||
"directly instead of the generated one, so you must call setAuthzCatalog(catalog) " +
|
||||
"yourself before importing anything that reads it eagerly; or (b) you're outside " +
|
||||
"the normal boot path entirely (a standalone script or test) and must call " +
|
||||
"setAuthzCatalog(catalog) first.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
|
||||
Reference in New Issue
Block a user