feat: centralize application framework primitives
Quality / quality (ubuntu-latest) (push) Failing after 14m38s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-22 23:07:46 +05:30
parent 96e082b943
commit a3ddd39b7b
73 changed files with 1429 additions and 84 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import type { DecisionPolicy } from "./advanced.ts";
export interface CatalogSource {
/** File or package that declared this module, used in conflict messages. */
source: string;
module: AuthzModule;
module: AuthzModule<never, never>;
}
/** Structural equality for declaration metadata. Key order is irrelevant. */
+40
View File
@@ -0,0 +1,40 @@
import { getDb } from "@wrnexus/db";
import { dbPermissionStore } from "./db.ts";
import type { PermissionStore } from "./store.ts";
export type AuthzIdentityEvent = "signup" | "invitation";
interface DefaultRoleState {
signup: string[];
invitation: string[];
store?: PermissionStore;
}
const key = Symbol.for("@wrnexus/authz:default-roles:v1");
function state(): DefaultRoleState {
const global = globalThis as Record<PropertyKey, unknown>;
return (global[key] ??= { signup: [], invitation: [] }) as DefaultRoleState;
}
export function setDefaultAuthzRoles(roles: Partial<DefaultRoleState>): void {
state().signup = [...(roles.signup ?? [])];
state().invitation = [...(roles.invitation ?? [])];
}
/** Bind lifecycle role assignment to the same store used by authorization middleware. */
export function setDefaultAuthzRoleStore(store: PermissionStore): void {
state().store = store;
}
/** Assign configured identity lifecycle roles through the framework-owned store. */
export async function assignDefaultAuthzRoles(
subjectId: string,
event: AuthzIdentityEvent,
): Promise<void> {
if (!subjectId) return;
const roles = state()[event];
if (!roles.length) return;
const store = state().store ?? dbPermissionStore(getDb());
await Promise.all(roles.map((role) => store.assignRole(subjectId, role)));
}
+8 -1
View File
@@ -157,8 +157,11 @@ export {
guardPermission,
filterCan,
AUTHZ_LOCALS_KEY,
getRequestAuthorization,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type { GuardOptions, AuthorizationResponses, RequestAuthorization } from "./middleware.ts";
export { defineAuthorizedHandler, defineOwnedResource } from "./resource.ts";
export type { AuthorizedHandlerOptions, OwnedResourceDefinition } from "./resource.ts";
export type {
AuthzScope,
AuthzCatalog,
@@ -169,3 +172,7 @@ export type {
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
export { generatePermissionTypes } from "./codegen.ts";
export { authzPlugin } from "./plugin.ts";
export type { AuthzConfig } from "./plugin.ts";
export { assignDefaultAuthzRoles, setDefaultAuthzRoles } from "./defaults.ts";
export type { AuthzIdentityEvent } from "./defaults.ts";
+52 -10
View File
@@ -10,6 +10,27 @@ import type { AuthzScope } from "./types.ts";
*/
export const AUTHZ_LOCALS_KEY = "_authz";
export interface AuthorizationResponses {
forbidden(decision?: AuthorizationDecision, options?: { exposeReason?: boolean }): Response;
notFoundOrForbidden(options?: {
mayDiscover?: boolean;
decision?: AuthorizationDecision;
exposeReason?: boolean;
}): Response;
}
export interface RequestAuthorization extends AuthorizationResponses {
can(permission: string, resource?: unknown): Promise<boolean>;
decide(permission: string, resource?: unknown): Promise<AuthorizationDecision>;
}
declare module "@wrnexus/core" {
interface Context {
/** Installed by authzMiddleware for handlers that prefer context-local authorization. */
authz?: RequestAuthorization;
}
}
interface RequestAuthz {
resolver: AuthzResolver;
/**
@@ -49,10 +70,39 @@ export function authzMiddleware(options: AuthzResolverOptions): Middleware {
byValue: new Map(),
};
ctx.locals[AUTHZ_LOCALS_KEY] = request;
ctx.authz = {
can: (permission, resource) => can(ctx, permission, resource),
decide: (permission, resource) => decideFor(ctx, permission, resource),
forbidden: (decision, responseOptions) =>
forbiddenResponse(decision, responseOptions?.exposeReason),
notFoundOrForbidden: (responseOptions = {}) =>
responseOptions.mayDiscover
? Response.json(
{ ok: false, error: "Not Found" },
{ status: 404, headers: NO_STORE_HEADERS },
)
: forbiddenResponse(responseOptions.decision, responseOptions.exposeReason),
};
return next();
};
}
function forbiddenResponse(decision?: AuthorizationDecision, exposeReason = false): Response {
return Response.json(
exposeReason
? { ok: false, error: "Forbidden", reason: decision?.reason, policy: decision?.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
export function getRequestAuthorization(ctx: Context): RequestAuthorization {
if (!ctx.authz) {
throw new Error("WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request.");
}
return ctx.authz;
}
/**
* Read the tenant from the context at decision time, not at middleware time:
* a request that switches tenant mid-flight must not keep the old scope.
@@ -236,10 +286,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
reason: "Resource unavailable",
at: Date.now(),
});
return Response.json(
{ ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse();
}
}
@@ -264,12 +311,7 @@ export function guardPermission(permission: string, options: GuardOptions = {}):
);
}
return Response.json(
options.exposeReason
? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
: { ok: false, error: "Forbidden" },
{ status: 403, headers: NO_STORE_HEADERS },
);
return forbiddenResponse(result, options.exposeReason);
};
}
+97
View File
@@ -0,0 +1,97 @@
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin, type PluginContext } from "@wrnexus/plugin";
import { authzMigrationSql } from "./migrations.ts";
import { setDefaultAuthzRoles } from "./defaults.ts";
export interface AuthzConfig {
enabled?: boolean;
declarations?: string;
store?: "database" | "memory";
migrations?: boolean;
middleware?: boolean;
strict?: boolean;
defaultRoles?: {
signup?: string[];
invitation?: string[];
};
}
interface ResolvedAuthzConfig {
enabled: boolean;
store: "database" | "memory";
migrations: boolean;
middleware: boolean;
strict: boolean;
defaultRoles: { signup: string[]; invitation: string[] };
driver: "sqlite" | "postgres" | "mysql";
}
const moduleRoot = dirname(fileURLToPath(import.meta.url));
const runtimeExtension = basename(moduleRoot) === "dist" ? ".js" : ".ts";
const middlewareEntry = join(moduleRoot, `runtime-middleware${runtimeExtension}`);
const memoryMiddlewareEntry = join(moduleRoot, `runtime-memory-middleware${runtimeExtension}`);
const metadataKey = "@wrnexus/authz:config";
function resolved(context: PluginContext): ResolvedAuthzConfig {
return (
(context.metadata.get(metadataKey) as ResolvedAuthzConfig | undefined) ?? {
enabled: false,
store: "database",
migrations: false,
middleware: false,
strict: true,
defaultRoles: { signup: [], invitation: [] },
driver: "sqlite",
}
);
}
export function authzPlugin() {
return definePlugin({
name: "@wrnexus/authz",
version: "0.8.0",
after: ["@wrnexus/auth"],
configure(config, context) {
const raw = (config.authz ?? {}) as AuthzConfig;
const enabled = raw.enabled !== false && Boolean(config.authz);
const db = config.db as { driver?: ResolvedAuthzConfig["driver"] } | undefined;
const value: ResolvedAuthzConfig = {
enabled,
store: raw.store ?? "database",
migrations: raw.migrations ?? enabled,
middleware: raw.middleware ?? enabled,
strict: raw.strict ?? true,
defaultRoles: {
signup: [...(raw.defaultRoles?.signup ?? [])],
invitation: [...(raw.defaultRoles?.invitation ?? [])],
},
driver: db?.driver ?? "sqlite",
};
if (enabled && value.store === "database" && !config.db) {
throw new Error("WRN-AUTHZ-CONFIG: authz.store='database' requires config.db");
}
context.metadata.set(metadataKey, value);
setDefaultAuthzRoles(value.defaultRoles);
config.authz = { ...raw, ...value };
},
middleware(context) {
const value = resolved(context);
if (!value.enabled || !value.middleware) return [];
return [value.store === "database" ? middlewareEntry : memoryMiddlewareEntry];
},
migrations(context) {
const value = resolved(context);
if (!value.enabled || !value.migrations || value.store !== "database") return [];
const sql = authzMigrationSql(value.driver);
return [
{
id: "wrnexus-authz-001",
source: `-- +up\n${sql.up.join(";\n")}\n;\n-- +down\n${sql.down.join(";\n")}\n;`,
},
];
},
});
}
export default authzPlugin;
+4 -2
View File
@@ -1,4 +1,4 @@
import type { AuthzModule } from "./types.ts";
import type { AuthzModule, DefinedAuthzModule } from "./types.ts";
const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
@@ -6,7 +6,9 @@ const PERMISSION_ID = /^[a-z0-9]+(?::[a-z0-9-]+)+$/;
* Validate and freeze one authorization declaration. Called from
* `app/authz/<name>.ts` as the module's default export.
*/
export function defineAuthz(module: AuthzModule): AuthzModule {
export function defineAuthz<Subject = any, Resource = any>(
module: AuthzModule<Subject, Resource>,
): DefinedAuthzModule<Subject, Resource> {
const permissions = module.permissions ?? {};
const roles = module.roles ?? {};
const policies = module.policies ?? {};
+59
View File
@@ -0,0 +1,59 @@
import type { Context } from "@wrnexus/core";
import { decideFor } from "./middleware.ts";
export interface AuthorizedHandlerOptions<Resource, Result extends Response = Response> {
permission: string;
resource?: (ctx: Context) => Resource | null | undefined | Promise<Resource | null | undefined>;
exposeReason?: boolean;
mayDiscover?: (ctx: Context) => boolean | Promise<boolean>;
handle(ctx: Context & { resource: Resource }): Result | Promise<Result>;
}
/** Define an API handler whose resource loading and permission check cannot be skipped. */
export function defineAuthorizedHandler<Resource = undefined, Result extends Response = Response>(
options: AuthorizedHandlerOptions<Resource, Result>,
): (ctx: Context) => Promise<Response> {
return async (ctx) => {
if (!ctx.user) return Response.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const resource = options.resource ? await options.resource(ctx) : (undefined as Resource);
if (options.resource && resource == null) {
const mayDiscover = await options.mayDiscover?.(ctx);
return mayDiscover
? Response.json({ ok: false, error: "Not Found" }, { status: 404 })
: Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const decision = await decideFor(ctx, options.permission, resource);
if (!decision.allowed) {
return ctx.authz!.forbidden(decision, { exposeReason: options.exposeReason });
}
ctx.resource = resource;
return options.handle(ctx as Context & { resource: Resource });
};
}
export interface OwnedResourceDefinition<Resource> {
name: string;
owner(resource: Resource): string | null | undefined;
permissions: {
read?: string;
create?: string;
update?: string;
delete?: string;
readAny?: string;
writeAny?: string;
};
}
/** Shared ownership and override vocabulary for application resource modules. */
export function defineOwnedResource<Resource>(definition: OwnedResourceDefinition<Resource>) {
return Object.freeze({
...definition,
attributes(resource: Resource) {
return { ownerId: definition.owner(resource) };
},
isOwner(subjectId: string, resource: Resource) {
const ownerId = definition.owner(resource);
return Boolean(subjectId && ownerId && subjectId === ownerId);
},
});
}
@@ -0,0 +1,12 @@
import { getAuthzCatalog } from "./client.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
import { memoryPermissionStore } from "./store.ts";
/** Process-local authorization storage for development and stateless tests. */
const store = memoryPermissionStore();
setDefaultAuthzRoleStore(store);
export default function frameworkMemoryAuthz(ctx: Context, next: Next) {
return authzMiddleware({ catalog: getAuthzCatalog(), store, strict: true })(ctx, next);
}
import type { Context, Next } from "@wrnexus/core";
+31
View File
@@ -0,0 +1,31 @@
import type { Context, Next } from "@wrnexus/core";
import { getDb, type Db } from "@wrnexus/db";
import { getAuthzCatalog } from "./client.ts";
import { dbPermissionStore, ensureAuthzTables } from "./db.ts";
import { setDefaultAuthzRoleStore } from "./defaults.ts";
import { authzMiddleware } from "./middleware.ts";
const initialized = new WeakMap<Db, Promise<void>>();
function initialize(db: Db): Promise<void> {
let pending = initialized.get(db);
if (!pending) {
pending = ensureAuthzTables(db);
initialized.set(db, pending);
pending.catch(() => initialized.delete(db));
}
return pending;
}
/** Package-installed authorization middleware with lazy database resolution. */
export default async function frameworkAuthz(ctx: Context, next: Next): Promise<Response> {
const db = getDb();
await initialize(db);
const store = dbPermissionStore(db);
setDefaultAuthzRoleStore(store);
return authzMiddleware({
catalog: getAuthzCatalog(),
store,
strict: true,
})(ctx, next);
}
+11 -2
View File
@@ -18,15 +18,24 @@ export interface AttributeMeta {
}
/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
export interface AuthzModule<Subject = any, Resource = any> {
permissions?: Record<string, PermissionMeta>;
roles?: Record<string, string[]>;
policies?: Record<string, DecisionPolicy<never, never>>;
policies?: Record<string, DecisionPolicy<Subject, Resource>>;
attributes?: Record<string, AttributeMeta>;
/** permission id -> policy names that must pass for it. */
bindings?: Record<string, string[]>;
}
/** A declaration after defineAuthz() has supplied all optional collections. */
export type DefinedAuthzModule<Subject = any, Resource = any> = {
permissions: Record<string, PermissionMeta>;
roles: Record<string, string[]>;
policies: Record<string, DecisionPolicy<Subject, Resource>>;
attributes: Record<string, AttributeMeta>;
bindings: Record<string, string[]>;
};
/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
permissions: ReadonlyMap<string, PermissionMeta>;