fix(dev-server): don't clobber a caller-set authz catalog; drop dead RuntimeDeps.authz
createProductionHandlers called setAuthzCatalog unconditionally, so a caller using client.ts's documented escape hatch (setAuthzCatalog(catalog) before importing anything that reads it) had that catalog silently wiped to empty whenever opts.authz was omitted. Now only sets when opts.authz has entries to contribute, or when nothing has been set yet; a non-empty opts.authz still always sets and still throws on a genuine conflict. Also removes RuntimeDeps.authz: nothing read it, and its doc comment described a consumer that doesn't exist. The real wiring is getAuthzCatalog()/setAuthzCatalog(), including the HMR hot-update path, which is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -472,7 +472,6 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
security: opts.security,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
authz: authzCatalog,
|
||||
navigation: opts.navigation,
|
||||
clientRuntimes: pluginContributions.clientRuntimes,
|
||||
hub,
|
||||
@@ -636,7 +635,6 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
|
||||
(file) => loadModule(file) as Promise<{ default?: AuthzModule }>,
|
||||
);
|
||||
setAuthzCatalog(nextAuthzCatalog);
|
||||
runtimeDeps.authz = nextAuthzCatalog;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus] authz hot update failed — the PREVIOUS catalog remains authoritative " +
|
||||
|
||||
@@ -38,7 +38,7 @@ import { VALIDATE_RUNTIME } from "@wrnexus/validation";
|
||||
import { I18N_RUNTIME, type ResolvedI18n } from "@wrnexus/i18n";
|
||||
import { setDb, registerLazyDb, getDb, hasDb, migrate } from "@wrnexus/db";
|
||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
||||
import { mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz";
|
||||
import { hasAuthzCatalog, mergeCatalogs, setAuthzCatalog, type AuthzModule } from "@wrnexus/authz";
|
||||
import {
|
||||
configureStorage,
|
||||
serveStoredFile,
|
||||
@@ -416,17 +416,25 @@ export function createProductionHandlers(
|
||||
// function was ever called, specifically so a middleware module that reads
|
||||
// getAuthzCatalog() at its own module scope sees a populated catalog — this
|
||||
// function's body runs too late for that (it is reached only once every
|
||||
// OTHER static import, including middleware, has already evaluated). This
|
||||
// pass still runs unconditionally (not skipped when the catalog is already
|
||||
// set) so a direct caller that bypasses the generated entry — and thus
|
||||
// never ran that early pass — still gets a correctly merged, validated
|
||||
// catalog, and so this function's own authorization handling stays fully
|
||||
// testable in isolation.
|
||||
// OTHER static import, including middleware, has already evaluated).
|
||||
//
|
||||
// The merge+validation of opts.authz always runs (a genuine conflict must
|
||||
// still fail the boot loudly, no matter which pass discovers it). But
|
||||
// setAuthzCatalog is only called when this pass actually has something to
|
||||
// contribute, OR when nothing has been set yet: client.ts documents an
|
||||
// escape hatch where a direct caller of createProductionHandlers may call
|
||||
// setAuthzCatalog(catalog) itself before importing anything that reads it,
|
||||
// specifically for a custom entry that never ran the generated
|
||||
// `.authz-setup.ts` pass. Calling setAuthzCatalog unconditionally here would
|
||||
// clobber that caller's catalog with an empty one whenever opts.authz is
|
||||
// omitted — silently deleting every permission the app declared.
|
||||
for (const missing of (opts.authz ?? []).filter((entry) => !entry.module)) {
|
||||
console.warn(`[wrnexus] authz declaration ${missing.source} has no default export; skipping.`);
|
||||
}
|
||||
const authzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? []));
|
||||
setAuthzCatalog(authzCatalog);
|
||||
const mergedAuthzCatalog = mergeCatalogs(resolveAuthzSources(opts.authz ?? []));
|
||||
if ((opts.authz?.length ?? 0) > 0 || !hasAuthzCatalog()) {
|
||||
setAuthzCatalog(mergedAuthzCatalog);
|
||||
}
|
||||
|
||||
// Middleware is already an ordered array of functions.
|
||||
const getMiddleware = async (): Promise<Middleware[]> => manifest.middleware;
|
||||
@@ -463,7 +471,6 @@ export function createProductionHandlers(
|
||||
security: opts.security,
|
||||
observability: opts.observability,
|
||||
tenancy: opts.tenancy,
|
||||
authz: authzCatalog,
|
||||
navigation: opts.navigation,
|
||||
maxBodyBytes: opts.maxBodyBytes,
|
||||
realtimeBus: realtimeBusFromConfig(opts.realtime),
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
requestStoreContainer,
|
||||
} from "@wrnexus/ssr/store-context";
|
||||
import type { StoreDefinition } from "@wrnexus/store";
|
||||
import type { AuthzCatalog } from "@wrnexus/authz";
|
||||
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
|
||||
import { CacheCoordinator } from "@wrnexus/cache";
|
||||
import { generateServiceWorker } from "@wrnexus/pwa";
|
||||
@@ -186,17 +185,6 @@ export interface RuntimeDeps {
|
||||
health?: HealthRegistry;
|
||||
/** Built-in tenant identity resolution. */
|
||||
tenancy?: TenancyConfig;
|
||||
/**
|
||||
* Process-wide authorization catalog, merged from `app/authz/*.ts` at boot
|
||||
* (dev: `loadAppAuthzCatalog`; prod: `mergeCatalogs` over the build's static
|
||||
* imports). Also reachable via `@wrnexus/authz`'s `getAuthzCatalog()`
|
||||
* singleton, which is what the app's own `authzMiddleware` registration
|
||||
* actually reads — this field exists so the request pipeline can see the
|
||||
* catalog without importing that singleton directly. The framework never
|
||||
* installs `authzMiddleware` itself; the app always registers it with its
|
||||
* own store.
|
||||
*/
|
||||
authz?: AuthzCatalog;
|
||||
/** Max request body size in bytes (413 above this). Default 10 MB. */
|
||||
maxBodyBytes?: number;
|
||||
/** HMR hub for browser live-update sockets (dev only). */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defineAuthz, getAuthzCatalog } from "@wrnexus/authz";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { defineAuthz, getAuthzCatalog, mergeCatalogs, setAuthzCatalog } from "@wrnexus/authz";
|
||||
import {
|
||||
applyAuthzManifestEarly,
|
||||
createProductionHandlers,
|
||||
@@ -15,13 +17,42 @@ const EMPTY_MANIFEST: ProdManifest = {
|
||||
layouts: [],
|
||||
};
|
||||
|
||||
describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => {
|
||||
test("an empty (or absent) authz array yields an empty catalog, no error", () => {
|
||||
createProductionHandlers(EMPTY_MANIFEST, { authz: [] });
|
||||
expect(getAuthzCatalog().permissions.size).toBe(0);
|
||||
const PROD_URL = pathToFileURL(join(import.meta.dir, "..", "src", "prod.ts")).href;
|
||||
|
||||
describe("createProductionHandlers authorization wiring (the idempotent second pass)", () => {
|
||||
test("an empty/absent authz array never throws, whatever the ambient catalog state", () => {
|
||||
createProductionHandlers(EMPTY_MANIFEST, { authz: [] });
|
||||
createProductionHandlers(EMPTY_MANIFEST, {});
|
||||
expect(getAuthzCatalog().permissions.size).toBe(0);
|
||||
});
|
||||
|
||||
test("starting from a genuinely unset catalog, an empty/absent authz array yields an empty catalog", async () => {
|
||||
// bun test does NOT isolate module instances between test files run in
|
||||
// the same invocation (see client.test.ts's comment on the same trap),
|
||||
// so "no catalog set yet" cannot be observed reliably in-process — some
|
||||
// other file's test may already have called setAuthzCatalog. A fresh
|
||||
// subprocess is the only way to guarantee that.
|
||||
const proc = Bun.spawn({
|
||||
cmd: [
|
||||
"bun",
|
||||
"-e",
|
||||
`const mod = await import(${JSON.stringify(PROD_URL)});
|
||||
const manifest = { pages: [], api: [], realtime: [], middleware: [], components: [], layouts: [] };
|
||||
mod.createProductionHandlers(manifest, { authz: [] });
|
||||
const { getAuthzCatalog } = await import("@wrnexus/authz");
|
||||
console.log("SIZE:" + getAuthzCatalog().permissions.size);`,
|
||||
],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: join(import.meta.dir, ".."),
|
||||
});
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("SIZE:0");
|
||||
});
|
||||
|
||||
test("a declaration with no default export warns and is skipped, not fatal", () => {
|
||||
@@ -107,6 +138,64 @@ describe("createProductionHandlers authorization wiring (the idempotent second p
|
||||
expect(getAuthzCatalog().permissions.has("a:read")).toBe(false);
|
||||
expect(getAuthzCatalog().permissions.has("b:read")).toBe(true);
|
||||
});
|
||||
|
||||
test("a caller-set catalog survives when opts.authz is omitted (the client.ts escape hatch)", () => {
|
||||
// client.ts documents that a direct caller of createProductionHandlers may
|
||||
// call setAuthzCatalog(catalog) itself, before importing anything that
|
||||
// reads it, when it bypasses the generated `.authz-setup.ts` entry. That
|
||||
// catalog must not be wiped just because this call's own opts.authz is
|
||||
// empty/absent.
|
||||
const preset = mergeCatalogs([
|
||||
{
|
||||
source: "preset.ts",
|
||||
module: defineAuthz({
|
||||
permissions: { "preset:read": {}, "preset:write": {}, "preset:delete": {} },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
setAuthzCatalog(preset);
|
||||
expect(getAuthzCatalog().permissions.size).toBe(3);
|
||||
|
||||
createProductionHandlers(EMPTY_MANIFEST, {});
|
||||
|
||||
expect(getAuthzCatalog()).toBe(preset);
|
||||
expect(getAuthzCatalog().permissions.size).toBe(3);
|
||||
expect(getAuthzCatalog().permissions.has("preset:read")).toBe(true);
|
||||
});
|
||||
|
||||
test("a non-empty opts.authz still sets (and still throws on a conflict), even over a pre-set catalog", () => {
|
||||
const preset = mergeCatalogs([
|
||||
{ source: "preset.ts", module: defineAuthz({ permissions: { "preset:read": {} } }) },
|
||||
]);
|
||||
setAuthzCatalog(preset);
|
||||
|
||||
// A non-empty authz array must still replace the pre-set catalog with the
|
||||
// merged result of ITS OWN declarations, not defer to the pre-set one.
|
||||
createProductionHandlers(EMPTY_MANIFEST, {
|
||||
authz: [{ source: "own.ts", module: defineAuthz({ permissions: { "own:read": {} } }) }],
|
||||
});
|
||||
expect(getAuthzCatalog()).not.toBe(preset);
|
||||
expect(getAuthzCatalog().permissions.has("own:read")).toBe(true);
|
||||
expect(getAuthzCatalog().permissions.has("preset:read")).toBe(false);
|
||||
|
||||
// And a genuine conflict inside that non-empty array still throws, exactly
|
||||
// as it did before this pass became conditional.
|
||||
setAuthzCatalog(preset);
|
||||
expect(() =>
|
||||
createProductionHandlers(EMPTY_MANIFEST, {
|
||||
authz: [
|
||||
{
|
||||
source: "a.ts",
|
||||
module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }),
|
||||
},
|
||||
{
|
||||
source: "b.ts",
|
||||
module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/WRN-AUTHZ-CONFLICT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAuthzManifestEarly (the eager, silent pass called only by the generated .authz-setup.ts)", () => {
|
||||
|
||||
Reference in New Issue
Block a user