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:
2026-08-04 19:52:37 +05:30
parent 703baa1ead
commit e7743cdbb5
5 changed files with 72 additions and 2 deletions
+16
View File
@@ -59,6 +59,8 @@ export interface Router {
stores: ComponentRef[];
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
schemas: ComponentRef[];
/** Authorization declarations (`app/authz/<name>.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/<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 {
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),
+10 -2
View File
@@ -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("/"),