feat(router): discover app/authz declarations
Scan app/authz/<name>.{ts,js} the same way app/schemas is scanned,
exposing Router.authz: ComponentRef[]. Also update the two other
literal Router construction sites (prod runtime, dev-server test
fixture) that now need the new required field.
scanDir gains an optional extraExtensions parameter (default []) so
the authz scan can accept .js files without widening the extension
allow-list used by route scanning (app/pages, app/api, app/realtime),
which would otherwise leak .js into generated route URLs via
fileToRoute.
This commit is contained in:
@@ -239,6 +239,7 @@ function buildProdRouter(manifest: ProdManifest): {
|
|||||||
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
|
layouts: manifest.layouts.map((l) => ({ name: l.name, file: `layout:${l.name}` })),
|
||||||
stores: [],
|
stores: [],
|
||||||
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
|
schemas: [], // descriptors are pre-baked into schemasJs; not needed at runtime
|
||||||
|
authz: [], // authz declarations are not needed at runtime in production
|
||||||
matchPage: optimizedMatcher(pages),
|
matchPage: optimizedMatcher(pages),
|
||||||
matchApi: optimizedMatcher(api),
|
matchApi: optimizedMatcher(api),
|
||||||
matchRealtime: optimizedMatcher(realtime),
|
matchRealtime: optimizedMatcher(realtime),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function runtime(health: HealthRegistry, trustProxy = false) {
|
|||||||
layouts: [],
|
layouts: [],
|
||||||
stores: [],
|
stores: [],
|
||||||
schemas: [],
|
schemas: [],
|
||||||
|
authz: [],
|
||||||
matchPage: () => null,
|
matchPage: () => null,
|
||||||
matchApi: () => null,
|
matchApi: () => null,
|
||||||
matchRealtime: () => null,
|
matchRealtime: () => null,
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface Router {
|
|||||||
stores: ComponentRef[];
|
stores: ComponentRef[];
|
||||||
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
|
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
|
||||||
schemas: ComponentRef[];
|
schemas: ComponentRef[];
|
||||||
|
/** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
|
||||||
|
authz: ComponentRef[];
|
||||||
matchPage(pathname: string): RouteMatch | null;
|
matchPage(pathname: string): RouteMatch | null;
|
||||||
matchApi(pathname: string): RouteMatch | null;
|
matchApi(pathname: string): RouteMatch | null;
|
||||||
matchRealtime(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 });
|
schemas.push({ name, file: f.file });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization declarations: app/authz/<name>.{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 {
|
return {
|
||||||
pages,
|
pages,
|
||||||
api,
|
api,
|
||||||
@@ -292,6 +307,7 @@ export function buildRouter(appDir: string, opts: RouterOptions = {}): Router {
|
|||||||
layouts,
|
layouts,
|
||||||
stores,
|
stores,
|
||||||
schemas,
|
schemas,
|
||||||
|
authz,
|
||||||
matchPage: (p) => matchRoute(pages, p),
|
matchPage: (p) => matchRoute(pages, p),
|
||||||
matchApi: (p) => matchRoute(api, p),
|
matchApi: (p) => matchRoute(api, p),
|
||||||
matchRealtime: (p) => matchRoute(realtime, p),
|
matchRealtime: (p) => matchRoute(realtime, p),
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ function isIgnored(name: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Recursively collect allowed route files under `baseDir`.
|
* Recursively collect allowed route files under `baseDir`.
|
||||||
* Returns [] if the directory does not exist (a route kind may be unused).
|
* 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 [];
|
if (!existsSync(baseDir)) return [];
|
||||||
|
|
||||||
const out: ScannedFile[] = [];
|
const out: ScannedFile[] = [];
|
||||||
@@ -45,7 +50,10 @@ export function scanDir(baseDir: string): ScannedFile[] {
|
|||||||
const stats = statSync(abs);
|
const stats = statSync(abs);
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
walk(abs);
|
walk(abs);
|
||||||
} else if (stats.isFile() && hasAllowedExtension(entry)) {
|
} else if (
|
||||||
|
stats.isFile() &&
|
||||||
|
(hasAllowedExtension(entry) || extraExtensions.some((ext) => entry.endsWith(ext)))
|
||||||
|
) {
|
||||||
out.push({
|
out.push({
|
||||||
file: abs,
|
file: abs,
|
||||||
rel: relative(baseDir, abs).split(sep).join("/"),
|
rel: relative(baseDir, abs).split(sep).join("/"),
|
||||||
|
|||||||
@@ -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, string>): 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user