diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index b0c14201..b54fe2ae 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -239,6 +239,7 @@ function buildProdRouter(manifest: ProdManifest): { layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })), stores: [], schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime + authz: [], // authz declarations are not needed at runtime in production matchPage: optimizedMatcher(pages), matchApi: optimizedMatcher(api), matchRealtime: optimizedMatcher(realtime), diff --git a/packages/dev-server/test/observability-runtime.test.ts b/packages/dev-server/test/observability-runtime.test.ts index d068a41d..154b48f1 100644 --- a/packages/dev-server/test/observability-runtime.test.ts +++ b/packages/dev-server/test/observability-runtime.test.ts @@ -13,6 +13,7 @@ function runtime(health: HealthRegistry, trustProxy = false) { layouts: [], stores: [], schemas: [], + authz: [], matchPage: () => null, matchApi: () => null, matchRealtime: () => null, diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 9e4ea4b6..5923b5e3 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -59,6 +59,8 @@ export interface Router { stores: ComponentRef[]; /** Validation schemas (`app/schemas/.ts`) shared by API + forms. */ schemas: ComponentRef[]; + /** Authorization declarations (`app/authz/.ts`) merged into the catalog. */ + authz: ComponentRef[]; matchPage(pathname: string): RouteMatch | null; matchApi(pathname: string): RouteMatch | null; matchRealtime(pathname: string): RouteMatch | null; @@ -283,6 +285,19 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { schemas.push({ name, file: f.file }); } + // Authorization declarations: app/authz/.{ts,js}, each default-exporting + // a defineAuthz() module. Merged into the catalog at boot. + const authz: ComponentRef[] = []; + for (const f of scanDir(join(appDir, "authz"), [".js"])) { + if (!/\.(ts|js)$/.test(f.file)) continue; + const name = basename(f.file).replace(/\.(ts|js)$/, ""); + if (!isSafeIslandName(name)) { + console.warn(`[wrnexus] skipping authz declaration with unsafe name: ${name}`); + continue; + } + authz.push({ name, file: f.file }); + } + return { pages, api, @@ -292,6 +307,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router { layouts, stores, schemas, + authz, matchPage: (p) => matchRoute(pages, p), matchApi: (p) => matchRoute(api, p), matchRealtime: (p) => matchRoute(realtime, p), diff --git a/packages/router/src/scan.ts b/packages/router/src/scan.ts index 724d39e1..7d6b7650 100644 --- a/packages/router/src/scan.ts +++ b/packages/router/src/scan.ts @@ -32,8 +32,13 @@ function isIgnored(name: string): boolean { /** * Recursively collect allowed route files under `baseDir`. * Returns [] if the directory does not exist (a route kind may be unused). + * + * `extraExtensions` widens the allow-list for callers that scan non-route + * directories (e.g. `app/schemas`, `app/authz`) and accept plain `.js` + * modules; it defaults to empty so route scanning (`app/pages`, `app/api`, + * `app/realtime`, ...) is unaffected. */ -export function scanDir(baseDir: string): ScannedFile[] { +export function scanDir(baseDir: string, extraExtensions: readonly string[] = []): ScannedFile[] { if (!existsSync(baseDir)) return []; const out: ScannedFile[] = []; @@ -45,7 +50,10 @@ export function scanDir(baseDir: string): ScannedFile[] { const stats = statSync(abs); if (stats.isDirectory()) { walk(abs); - } else if (stats.isFile() && hasAllowedExtension(entry)) { + } else if ( + stats.isFile() && + (hasAllowedExtension(entry) || extraExtensions.some((ext) => entry.endsWith(ext))) + ) { out.push({ file: abs, rel: relative(baseDir, abs).split(sep).join("/"), diff --git a/packages/router/test/authz-discovery.test.ts b/packages/router/test/authz-discovery.test.ts new file mode 100644 index 00000000..438e7fbe --- /dev/null +++ b/packages/router/test/authz-discovery.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildRouter } from "../src/index.ts"; + +function appWithAuthz(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-")); + const dir = join(root, "app", "authz"); + mkdirSync(dir, { recursive: true }); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, "utf8"); + return join(root, "app"); +} + +describe("app/authz discovery", () => { + test("collects .ts and .js declarations by filename", () => { + const appDir = appWithAuthz({ + "blog.ts": "export default {};", + "billing.js": "export default {};", + }); + const router = buildRouter(appDir); + expect(router.authz.map((entry) => entry.name).sort()).toEqual(["billing", "blog"]); + }); + + test("ignores non-module files", () => { + const appDir = appWithAuthz({ "blog.ts": "export default {};", "notes.md": "# hi" }); + expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["blog"]); + }); + + test("skips unsafe names", () => { + const appDir = appWithAuthz({ + "ok.ts": "export default {};", + "bad name!.ts": "export default {};", + }); + expect(buildRouter(appDir).authz.map((entry) => entry.name)).toEqual(["ok"]); + }); + + test("an app with no authz directory yields an empty list", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-none-")); + mkdirSync(join(root, "app", "pages"), { recursive: true }); + expect(buildRouter(join(root, "app")).authz).toEqual([]); + }); +});