Files
WRNexusJS/docs/plans/2026-08-04-authz-permissions-implementation.md
T
ClintchizandClaude Opus 5 e5d0654d2a docs: join the DDL statement lists in the Task 13 init command
authzMigrationSql was changed in Task 11 to return statement arrays rather
than one blob, but Task 13's init still interpolated them straight into the
migration file, which would comma-join two CREATE TABLE statements into one
unparseable line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:26:10 +05:30

129 KiB

Permissions System Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extend @wrnexus/authz so permissions, roles, policies and attributes are declared in code and discoverable, while role assignments live in a pluggable store.

Architecture: A registry (defineAuthz) declares what exists; a catalog merges declarations and freezes at boot; a store (PermissionStore) holds who-has-what; an engine resolves a subject to effective permissions and returns an AuthorizationDecision. The decision primitives already in advanced.ts are the evaluation layer and are not replaced.

Tech Stack: TypeScript, Bun (bun:test), @wrnexus/core (Context/Middleware types only), @wrnexus/db (Db interface, migrations).

Global Constraints

  • Every @wrnexus/* package is version 0.8.4. Do not change versions.
  • Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins.
  • @wrnexus/core MUST NOT import @wrnexus/authz. can() stays off Context; the resolver lives in ctx.locals._authz.
  • @wrnexus/authz may import types only from @wrnexus/core (import type { Context, Middleware }).
  • Existing exports of @wrnexus/authz keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of authorizeDecision to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive.
  • Framework-owned tables use the _wrn_ prefix (matching _wrn_tenant, _wrn_cursor). The spec wrote wrn_authz_assignment; use _wrn_authz_assignment and _wrn_authz_grant.
  • requirePermission is already exported with signature (rbac: Rbac, permission: string). Do not change it. The new resource-aware guard is named guardPermission.
  • Every failure path denies. Never fail open.
  • After any change to packages/authz/src/index.ts exports, regenerate the API baseline with bun run generate:public-api.
  • Full gate before declaring done: bun run check:production.
  • Test files live in packages/<pkg>/test/*.test.ts and use import { describe, expect, test } from "bun:test".

File Structure

Created:

File Responsibility
packages/authz/src/types.ts Shared types: AuthzScope, PermissionMeta, AuthzModule, AuthzCatalog, SubjectAssignments
packages/authz/src/registry.ts defineAuthz() — validate and freeze one declaration module
packages/authz/src/catalog.ts mergeCatalogs() — merge modules, detect conflicts, freeze
packages/authz/src/store.ts PermissionStore interface, memoryPermissionStore(), cachedPermissionStore()
packages/authz/src/audit.ts AuthzAuditSink, memoryAuditSink(), consoleAuditSink()
packages/authz/src/engine.ts createAuthzResolver() — effective permissions, precedence, fail-closed
packages/authz/src/middleware.ts authzMiddleware(), can(), decideFor(), guardPermission()
packages/authz/src/db.ts dbPermissionStore(db) — subpath export @wrnexus/authz/db
packages/authz/src/migrations.ts authzMigrationSql(dialect) — DDL for the two tables
packages/authz/src/codegen.ts generatePermissionTypes(catalog) — emits the Permission/Role unions
packages/authz/test/store-conformance.ts Shared suite both store adapters must pass (not a .test.ts)
packages/cli/src/authz.ts runAuthzCommand(root, sub, args) for list / init / generate

Modified:

File Change
packages/authz/src/index.ts Re-export the new surface
packages/authz/src/advanced.ts authorizeDecision gains { exposeReason }, defaulting to off
packages/authz/package.json Add ./db subpath export
packages/router/src/index.ts Discover app/authz/*.{ts,js} into router.authz
packages/cli/src/index.ts Dispatch case "authz"
docs/public-api-0.8.json Regenerated baseline

Task 1: Types and registry

Files:

  • Create: packages/authz/src/types.ts
  • Create: packages/authz/src/registry.ts
  • Test: packages/authz/test/registry.test.ts

Interfaces:

  • Consumes: DecisionPolicy from ./advanced.ts

  • Produces: AuthzScope, PermissionMeta, AuthzModule, AuthzCatalog, SubjectAssignments, defineAuthz(module: AuthzModule): AuthzModule

  • Step 1: Write the failing test

Create packages/authz/test/registry.test.ts:

import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";

describe("defineAuthz", () => {
  test("returns a frozen module", () => {
    const mod = defineAuthz({
      permissions: { "post:read": { title: "View posts" } },
      roles: { editor: ["post:*"] },
    });
    expect(Object.isFrozen(mod)).toBe(true);
    expect(mod.permissions!["post:read"]!.title).toBe("View posts");
    expect(mod.roles!.editor).toEqual(["post:*"]);
  });

  test("defaults missing sections to empty objects", () => {
    const mod = defineAuthz({});
    expect(mod.permissions).toEqual({});
    expect(mod.roles).toEqual({});
    expect(mod.policies).toEqual({});
    expect(mod.attributes).toEqual({});
    expect(mod.bindings).toEqual({});
  });

  test("rejects a permission id that is not colon-namespaced lowercase", () => {
    expect(() => defineAuthz({ permissions: { "Post Read": {} } })).toThrow(/permission id/i);
    expect(() => defineAuthz({ permissions: { "post:*": {} } })).toThrow(/wildcard/i);
  });

  test("rejects a role granting an unknown-shaped entry", () => {
    expect(() => defineAuthz({ roles: { editor: [""] } })).toThrow(/role 'editor'/i);
  });

  test("rejects a binding naming a policy that is not declared", () => {
    expect(() =>
      defineAuthz({
        permissions: { "post:write": {} },
        bindings: { "post:write": ["missingPolicy"] },
      }),
    ).toThrow(/missingPolicy/);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/registry.test.ts Expected: FAIL — cannot resolve ../src/registry.ts

  • Step 3: Write the types

Create packages/authz/src/types.ts:

import type { DecisionPolicy } from "./advanced.ts";

/** Narrows an assignment to a tenant. Absent means a global assignment. */
export interface AuthzScope {
  tenantId?: string;
}

export interface PermissionMeta {
  title?: string;
  description?: string;
  risk?: "low" | "medium" | "high";
  /** Granted to anonymous subjects. Every other permission denies without a user. */
  public?: boolean;
}

export interface AttributeMeta {
  description?: string;
}

/** One `app/authz/<name>.ts` declaration. */
export interface AuthzModule {
  permissions?: Record<string, PermissionMeta>;
  roles?: Record<string, string[]>;
  policies?: Record<string, DecisionPolicy<never, never>>;
  attributes?: Record<string, AttributeMeta>;
  /** permission id -> policy names that must pass for it. */
  bindings?: Record<string, string[]>;
}

/** The merged, frozen view of every declaration in the app. */
export interface AuthzCatalog {
  permissions: ReadonlyMap<string, PermissionMeta>;
  roles: ReadonlyMap<string, readonly string[]>;
  policies: ReadonlyMap<string, DecisionPolicy<never, never>>;
  attributes: ReadonlyMap<string, AttributeMeta>;
  bindings: ReadonlyMap<string, readonly string[]>;
}

export interface SubjectAssignments {
  roles: string[];
  /** Explicit allows, bypassing roles. */
  grants: string[];
  /** Explicit denies. Win over everything, including "*". */
  denies: string[];
}
  • Step 4: Write the registry

Create packages/authz/src/registry.ts:

import type { AuthzModule } from "./types.ts";

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 {
  const permissions = module.permissions ?? {};
  const roles = module.roles ?? {};
  const policies = module.policies ?? {};
  const attributes = module.attributes ?? {};
  const bindings = module.bindings ?? {};

  for (const id of Object.keys(permissions)) {
    if (id.includes("*")) {
      throw new Error(
        `WRN-AUTHZ-DECL: permission id '${id}' must not contain a wildcard; wildcards belong in roles.`,
      );
    }
    if (!PERMISSION_ID.test(id)) {
      throw new Error(
        `WRN-AUTHZ-DECL: permission id '${id}' must be lowercase colon-namespaced, e.g. 'post:read'.`,
      );
    }
  }

  for (const [role, grants] of Object.entries(roles)) {
    for (const grant of grants) {
      if (typeof grant !== "string" || !grant.trim()) {
        throw new Error(
          `WRN-AUTHZ-DECL: role '${role}' grants an empty entry; expected a permission, 'ns:*', or 'role:<name>'.`,
        );
      }
    }
  }

  for (const [permission, names] of Object.entries(bindings)) {
    for (const name of names) {
      if (!(name in policies)) {
        throw new Error(
          `WRN-AUTHZ-DECL: binding for '${permission}' names policy '${name}', which is not declared in the same module.`,
        );
      }
    }
  }

  return Object.freeze({ permissions, roles, policies, attributes, bindings });
}
  • Step 5: Run test to verify it passes

Run: bun test packages/authz/test/registry.test.ts Expected: PASS, 5 tests

  • Step 6: Commit
git add packages/authz/src/types.ts packages/authz/src/registry.ts packages/authz/test/registry.test.ts
git commit -m "feat(authz): add defineAuthz declaration registry"

Task 2: Catalog merge and conflict detection

Files:

  • Create: packages/authz/src/catalog.ts
  • Test: packages/authz/test/catalog.test.ts

Interfaces:

  • Consumes: AuthzModule, AuthzCatalog from ./types.ts; defineAuthz from ./registry.ts

  • Produces: mergeCatalogs(sources: CatalogSource[]): AuthzCatalog, interface CatalogSource { source: string; module: AuthzModule }, emptyCatalog(): AuthzCatalog

  • Step 1: Write the failing test

Create packages/authz/test/catalog.test.ts:

import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts";

describe("mergeCatalogs", () => {
  test("merges disjoint modules", () => {
    const catalog = mergeCatalogs([
      { source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) },
      { source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) },
    ]);
    expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]);
  });

  test("re-declaring a permission with deep-equal metadata is a no-op", () => {
    const meta = { title: "View posts", risk: "low" as const };
    const catalog = mergeCatalogs([
      { source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) },
      { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) },
    ]);
    expect(catalog.permissions.size).toBe(1);
  });

  test("conflicting metadata is a boot error naming both files", () => {
    expect(() =>
      mergeCatalogs([
        { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) },
        { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) },
      ]),
    ).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s);
  });

  test("conflicting role definitions are a boot error", () => {
    expect(() =>
      mergeCatalogs([
        { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) },
        { source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) },
      ]),
    ).toThrow(/editor/);
  });

  test("bindings for the same permission union across modules", () => {
    const p1 = defineAuthz({
      permissions: { "post:write": {} },
      policies: { ownsPost: async () => ({ allowed: true }) },
      bindings: { "post:write": ["ownsPost"] },
    });
    const p2 = defineAuthz({
      policies: { notLocked: async () => ({ allowed: true }) },
      bindings: { "post:write": ["notLocked"] },
    });
    const catalog = mergeCatalogs([
      { source: "a.ts", module: p1 },
      { source: "b.ts", module: p2 },
    ]);
    expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]);
  });

  test("a binding referencing a policy no module declares is a boot error", () => {
    expect(() =>
      mergeCatalogs([
        {
          source: "a.ts",
          module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } },
        },
      ]),
    ).toThrow(/ghost/);
  });

  test("the merged catalog is frozen", () => {
    const catalog = mergeCatalogs([]);
    expect(() => (catalog.permissions as Map<string, never>).set("x:y", {} as never)).toThrow();
  });

  test("emptyCatalog has no entries", () => {
    expect(emptyCatalog().permissions.size).toBe(0);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/catalog.test.ts Expected: FAIL — cannot resolve ../src/catalog.ts

  • Step 3: Write the implementation

Create packages/authz/src/catalog.ts:

import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts";
import type { DecisionPolicy } from "./advanced.ts";

export interface CatalogSource {
  /** File or package that declared this module, used in conflict messages. */
  source: string;
  module: AuthzModule;
}

/** Structural equality for declaration metadata. Key order is irrelevant. */
function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const left = a as Record<string, unknown>;
  const right = b as Record<string, unknown>;
  const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
  for (const key of keys) if (!deepEqual(left[key], right[key])) return false;
  return true;
}

/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */
function frozenMap<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
  const map = new Map(entries);
  const reject = () => {
    throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot.");
  };
  map.set = reject as never;
  map.delete = reject as never;
  map.clear = reject as never;
  return map;
}

export function emptyCatalog(): AuthzCatalog {
  return {
    permissions: frozenMap<PermissionMeta>([]),
    roles: frozenMap<readonly string[]>([]),
    policies: frozenMap<DecisionPolicy<never, never>>([]),
    attributes: frozenMap<AttributeMeta>([]),
    bindings: frozenMap<readonly string[]>([]),
  };
}

export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
  const permissions = new Map<string, PermissionMeta>();
  const roles = new Map<string, readonly string[]>();
  const policies = new Map<string, DecisionPolicy<never, never>>();
  const attributes = new Map<string, AttributeMeta>();
  const bindings = new Map<string, Set<string>>();
  const origin = new Map<string, string>();

  const claim = (
    kind: string,
    key: string,
    source: string,
    existingValue: unknown,
    value: unknown,
  ) => {
    const previous = origin.get(`${kind}:${key}`);
    if (previous === undefined) {
      origin.set(`${kind}:${key}`, source);
      return;
    }
    if (!deepEqual(existingValue, value)) {
      throw new Error(
        `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`,
      );
    }
  };

  for (const { source, module } of sources) {
    for (const [id, meta] of Object.entries(module.permissions ?? {})) {
      claim("permission", id, source, permissions.get(id), meta);
      permissions.set(id, meta);
    }
    for (const [name, grants] of Object.entries(module.roles ?? {})) {
      claim("role", name, source, roles.get(name), grants);
      roles.set(name, grants);
    }
    for (const [name, policy] of Object.entries(module.policies ?? {})) {
      // Two closures are never deep-equal, so identity is the only sane test.
      const existing = policies.get(name);
      if (existing && existing !== policy) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`,
        );
      }
      origin.set(`policy:${name}`, source);
      policies.set(name, policy);
    }
    for (const [name, meta] of Object.entries(module.attributes ?? {})) {
      claim("attribute", name, source, attributes.get(name), meta);
      attributes.set(name, meta);
    }
    for (const [permission, names] of Object.entries(module.bindings ?? {})) {
      const set = bindings.get(permission) ?? new Set<string>();
      for (const name of names) set.add(name);
      bindings.set(permission, set);
    }
  }

  for (const [permission, names] of bindings) {
    for (const name of names) {
      if (!policies.has(name)) {
        throw new Error(
          `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`,
        );
      }
    }
  }

  return {
    permissions: frozenMap(permissions),
    roles: frozenMap(roles),
    policies: frozenMap(policies),
    attributes: frozenMap(attributes),
    bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])),
  };
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/catalog.test.ts Expected: PASS, 8 tests

  • Step 5: Commit
git add packages/authz/src/catalog.ts packages/authz/test/catalog.test.ts
git commit -m "feat(authz): merge declaration modules into a frozen catalog"

Task 3: PermissionStore interface, memory adapter, conformance suite

Files:

  • Create: packages/authz/src/store.ts
  • Create: packages/authz/test/store-conformance.ts
  • Test: packages/authz/test/store-memory.test.ts

Interfaces:

  • Consumes: AuthzScope, SubjectAssignments from ./types.ts

  • Produces: PermissionStore, memoryPermissionStore(): PermissionStore, runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>)

  • Step 1: Write the conformance suite

Create packages/authz/test/store-conformance.ts. This is imported by adapter tests; it has no .test.ts suffix so Bun does not run it directly.

import { beforeEach, describe, expect, test } from "bun:test";
import type { PermissionStore } from "../src/store.ts";

/**
 * Every PermissionStore adapter must pass this suite, so the memory and db
 * implementations cannot drift apart.
 */
export function runStoreConformance(name: string, makeStore: () => Promise<PermissionStore>): void {
  describe(`PermissionStore conformance: ${name}`, () => {
    let store: PermissionStore;
    beforeEach(async () => {
      store = await makeStore();
    });

    test("an unknown subject has empty assignments", async () => {
      expect(await store.assignmentsFor("nobody")).toEqual({
        roles: [],
        grants: [],
        denies: [],
      });
    });

    test("assignRole then assignmentsFor round-trips", async () => {
      await store.assignRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("assignRole is idempotent", async () => {
      await store.assignRole("u1", "editor");
      await store.assignRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("revokeRole removes only that role", async () => {
      await store.assignRole("u1", "editor");
      await store.assignRole("u1", "admin");
      await store.revokeRole("u1", "editor");
      expect((await store.assignmentsFor("u1")).roles).toEqual(["admin"]);
    });

    test("revoking a role that was never assigned is a no-op", async () => {
      await store.revokeRole("u1", "ghost");
      expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    });

    test("scoped assignments do not leak across tenants", async () => {
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
      expect((await store.assignmentsFor("u1", { tenantId: "t2" })).roles).toEqual([]);
    });

    test("a global assignment is visible inside every tenant", async () => {
      await store.assignRole("u1", "superadmin");
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["superadmin"]);
    });

    test("global and scoped roles union within a tenant", async () => {
      await store.assignRole("u1", "viewer");
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles.sort()).toEqual([
        "editor",
        "viewer",
      ]);
    });

    test("grant with allow and deny land in the right buckets", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.grant("u1", "post:delete", "deny");
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants).toEqual(["post:write"]);
      expect(assignments.denies).toEqual(["post:delete"]);
    });

    test("re-granting the same permission replaces its effect", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.grant("u1", "post:write", "deny");
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants).toEqual([]);
      expect(assignments.denies).toEqual(["post:write"]);
    });

    test("revokeGrant removes the permission entirely", async () => {
      await store.grant("u1", "post:write", "allow");
      await store.revokeGrant("u1", "post:write");
      expect((await store.assignmentsFor("u1")).grants).toEqual([]);
    });

    test("listSubjects returns everyone with an assignment in scope", async () => {
      await store.assignRole("u1", "editor", { tenantId: "t1" });
      await store.assignRole("u2", "editor", { tenantId: "t1" });
      await store.assignRole("u3", "editor", { tenantId: "t2" });
      expect((await store.listSubjects({ tenantId: "t1" })).sort()).toEqual(["u1", "u2"]);
    });

    test("an explicitly empty tenantId is refused, not treated as global", async () => {
      await store.assignRole("g1", "viewer");
      // Otherwise a caller who controls the tenant id reaches global scope.
      await expect(store.assignmentsFor("g1", { tenantId: "" })).rejects.toThrow(/tenantId/);
      await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
    });

    test("a non-string tenantId is refused", async () => {
      // Same class as the empty-string case: the caller controls this value.
      for (const bad of [null, 0, false, {}]) {
        await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow(
          /tenantId/,
        );
      }
    });

    test("concurrent identical assignRole calls all resolve", async () => {
      // Check-then-act loses this race; the UNIQUE constraint then rejects
      // every loser even though the desired end state was already reached.
      await Promise.all(Array.from({ length: 20 }, () => store.assignRole("u1", "editor")));
      expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
    });

    test("concurrent grants on distinct keys all resolve", async () => {
      await Promise.all([
        store.grant("u1", "post:read", "allow"),
        store.grant("u1", "post:write", "allow"),
        store.grant("u1", "post:delete", "deny"),
      ]);
      const assignments = await store.assignmentsFor("u1");
      expect(assignments.grants.sort()).toEqual(["post:read", "post:write"]);
      expect(assignments.denies).toEqual(["post:delete"]);
    });

    test("a rejected write leaves unrelated state intact", async () => {
      await store.assignRole("victim", "admin");
      await store.grant("victim", "post:read", "allow");
      // An invalid effect must be refused without disturbing anything else.
      await expect(store.grant("victim", "post:write", "bogus" as never)).rejects.toThrow();
      const assignments = await store.assignmentsFor("victim");
      expect(assignments.roles).toEqual(["admin"]);
      expect(assignments.grants).toEqual(["post:read"]);
    });

    // NOTE: the shared-connection rollback hazard - where one method's open
    // transaction sweeps in a concurrent bare write from another method and
    // discards it, so a revoke resolves successfully while the role survives -
    // is prevented STRUCTURALLY, by the store using no transactions at all.
    // It is deliberately not covered here: reproducing it needs the bare write
    // to land inside the open transaction, which a single-process Promise.all
    // does not reliably arrange, so any such test would pass against the
    // defective implementation and give false assurance.

    test("listSubjects with no scope returns global assignees only", async () => {
      await store.assignRole("g1", "viewer");
      await store.assignRole("s1", "editor", { tenantId: "t1" });
      expect(await store.listSubjects()).toEqual(["g1"]);
    });
  });
}
  • Step 2: Write the memory adapter test

Create packages/authz/test/store-memory.test.ts:

import { memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";

runStoreConformance("memory", async () => memoryPermissionStore());
  • Step 3: Run test to verify it fails

Run: bun test packages/authz/test/store-memory.test.ts Expected: FAIL — cannot resolve ../src/store.ts

  • Step 4: Write the implementation

Create packages/authz/src/store.ts:

import type { AuthzScope, SubjectAssignments } from "./types.ts";

export type GrantEffect = "allow" | "deny";

export interface PermissionStore {
  assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments>;
  assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
  revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
  grant(
    subjectId: string,
    permission: string,
    effect: GrantEffect,
    scope?: AuthzScope,
  ): Promise<void>;
  revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
  listSubjects(scope?: AuthzScope): Promise<string[]>;
}

/**
 * Global assignments are stored under the empty-string scope key. An OMITTED
 * scope means global; an explicitly EMPTY tenantId is refused, because it is
 * indistinguishable from global and would let a caller who controls the tenant
 * id read and write global assignments.
 */
export function scopeKey(scope?: AuthzScope): string {
  const tenantId = scope?.tenantId;
  if (tenantId === undefined) return "";
  // Guard the TYPE as well as the value: a null from a JSON body or a nullable
  // column would otherwise flow through un-normalised and the adapters would
  // disagree about what happened - the db rejects on NOT NULL, memory accepts
  // an unreachable row.
  if (typeof tenantId !== "string" || tenantId === "") {
    throw new Error(
      "WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.",
    );
  }
  return tenantId;
}

interface Row {
  subjectId: string;
  scope: string;
}
interface RoleRow extends Row {
  role: string;
}
interface GrantRow extends Row {
  permission: string;
  effect: GrantEffect;
}

export function memoryPermissionStore(): PermissionStore {
  const roles: RoleRow[] = [];
  const grants: GrantRow[] = [];

  // A request inside tenant t sees global assignments plus t's own.
  const visible = (row: Row, key: string) => row.scope === "" || row.scope === key;

  return {
    async assignmentsFor(subjectId, scope) {
      const key = scopeKey(scope);
      const mine = (row: Row) => row.subjectId === subjectId && visible(row, key);
      const matched = grants.filter(mine);
      return {
        roles: roles.filter(mine).map((row) => row.role),
        grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission),
        denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission),
      };
    },
    async assignRole(subjectId, role, scope) {
      const key = scopeKey(scope);
      if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role))
        return;
      roles.push({ subjectId, scope: key, role });
    },
    async revokeRole(subjectId, role, scope) {
      const key = scopeKey(scope);
      const at = roles.findIndex(
        (r) => r.subjectId === subjectId && r.scope === key && r.role === role,
      );
      if (at !== -1) roles.splice(at, 1);
    },
    async grant(subjectId, permission, effect, scope) {
      const key = scopeKey(scope);
      const at = grants.findIndex(
        (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
      );
      if (at !== -1) grants.splice(at, 1);
      grants.push({ subjectId, scope: key, permission, effect });
    },
    async revokeGrant(subjectId, permission, scope) {
      const key = scopeKey(scope);
      const at = grants.findIndex(
        (g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
      );
      if (at !== -1) grants.splice(at, 1);
    },
    async listSubjects(scope) {
      const key = scopeKey(scope);
      const ids = new Set<string>();
      for (const row of roles) if (row.scope === key) ids.add(row.subjectId);
      for (const row of grants) if (row.scope === key) ids.add(row.subjectId);
      return [...ids];
    },
  };
}
  • Step 5: Run test to verify it passes

Run: bun test packages/authz/test/store-memory.test.ts Expected: PASS, 13 tests

  • Step 6: Commit
git add packages/authz/src/store.ts packages/authz/test/store-conformance.ts packages/authz/test/store-memory.test.ts
git commit -m "feat(authz): add PermissionStore contract with memory adapter and conformance suite"

Task 4: Cached store decorator

Files:

  • Modify: packages/authz/src/store.ts (append)
  • Test: packages/authz/test/store-cached.test.ts

Interfaces:

  • Consumes: PermissionStore, scopeKey from ./store.ts

  • Produces: cachedPermissionStore(inner: PermissionStore, options?: { ttlMs?: number; max?: number }): CachedPermissionStore, interface CachedPermissionStore extends PermissionStore { invalidate(subjectId: string, scope?: AuthzScope): void; invalidateAll(): void }

  • Step 1: Write the failing test

Create packages/authz/test/store-cached.test.ts:

import { describe, expect, test } from "bun:test";
import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts";
import { runStoreConformance } from "./store-conformance.ts";

// A cache must not change observable behaviour: writes invalidate internally.
runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore()));

describe("cachedPermissionStore", () => {
  test("serves a repeat read from cache", async () => {
    const inner = memoryPermissionStore();
    let reads = 0;
    const counting = {
      ...inner,
      assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
        reads++;
        return inner.assignmentsFor(id, scope);
      },
    };
    const store = cachedPermissionStore(counting, { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await store.assignmentsFor("u1");
    expect(reads).toBe(1);
  });

  test("a write invalidates that subject", async () => {
    const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await store.assignRole("u1", "editor");
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("invalidate() drops a cached subject", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await store.assignmentsFor("u1");
    await inner.assignRole("u1", "editor"); // behind the cache's back
    expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    store.invalidate("u1");
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("entries expire after ttlMs", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 1 });
    await store.assignmentsFor("u1");
    await inner.assignRole("u1", "editor");
    await Bun.sleep(5);
    expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]);
  });

  test("cache is bounded by max", async () => {
    const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 });
    await store.assignmentsFor("a");
    await store.assignmentsFor("b");
    await store.assignmentsFor("c");
    expect(store.size()).toBeLessThanOrEqual(2);
  });

  test("a global write invalidates the subject in every tenant", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await store.assignmentsFor("u1", { tenantId: "t1" }); // warm the tenant entry
    await store.assignRole("u1", "editor"); // global write
    // Global roles are visible inside every tenant, so the cached t1 entry
    // must not survive this write.
    expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
  });

  test("cache keys cannot collide across subject/tenant boundaries", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    // Naive "scope + separator + subject" concatenation makes these two pairs
    // produce the same key, serving one subject the other's permissions.
    await inner.assignRole("bc", "editor", { tenantId: "a" });
    expect((await store.assignmentsFor("bc", { tenantId: "a" })).roles).toEqual(["editor"]);
    expect((await store.assignmentsFor("c", { tenantId: "ab" })).roles).toEqual([]);
  });

  test("scoped and global reads cache separately", async () => {
    const inner = memoryPermissionStore();
    const store = cachedPermissionStore(inner, { ttlMs: 60_000 });
    await inner.assignRole("u1", "editor", { tenantId: "t1" });
    expect((await store.assignmentsFor("u1")).roles).toEqual([]);
    expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/store-cached.test.ts Expected: FAIL — cachedPermissionStore is not exported

  • Step 3: Append the implementation to packages/authz/src/store.ts
export interface CachedPermissionStore extends PermissionStore {
  /** Drop one subject. Call after changing roles out of band. */
  invalidate(subjectId: string, scope?: AuthzScope): void;
  invalidateAll(): void;
  /** Cached entry count, for tests and diagnostics. */
  size(): number;
}

export interface CacheOptions {
  ttlMs?: number;
  max?: number;
}

/**
 * Caches assignment reads. Writes through this decorator invalidate the
 * affected subject immediately; changes made directly against the inner store
 * need an explicit `invalidate()` call rather than waiting out the TTL.
 */
export function cachedPermissionStore(
  inner: PermissionStore,
  options: CacheOptions = {},
): CachedPermissionStore {
  const ttlMs = options.ttlMs ?? 5_000;
  const max = options.max ?? 1_000;
  const entries = new Map<string, { at: number; value: SubjectAssignments }>();

  // Subject and tenant ids are unconstrained strings, so the key must be
  // unambiguous: concatenating around a separator lets ("a", "b<sep>c") and
  // ("a<sep>b", "c") collide, which would serve one subject another's
  // permissions. JSON encoding escapes the components.
  const cacheKey = (subjectId: string, scope?: AuthzScope) =>
    JSON.stringify([scopeKey(scope), subjectId]);
  // Track subjects separately rather than pattern-matching key strings, so a
  // global write can find every tenant entry without substring guesswork.
  const bySubject = new Map<string, Set<string>>();
  const drop = (subjectId: string, scope?: AuthzScope) => {
    // A global write changes what every tenant sees for that subject.
    if (scopeKey(scope) === "") {
      for (const key of bySubject.get(subjectId) ?? []) entries.delete(key);
      bySubject.delete(subjectId);
      return;
    }
    const key = cacheKey(subjectId, scope);
    entries.delete(key);
    bySubject.get(subjectId)?.delete(key);
  };

  return {
    async assignmentsFor(subjectId, scope) {
      const key = cacheKey(subjectId, scope);
      const hit = entries.get(key);
      if (hit && Date.now() - hit.at < ttlMs) return hit.value;
      const value = await inner.assignmentsFor(subjectId, scope);
      if (entries.size >= max) {
        const oldest = entries.keys().next().value!;
        entries.delete(oldest);
        for (const keys of bySubject.values()) keys.delete(oldest);
      }
      entries.set(key, { at: Date.now(), value });
      let keys = bySubject.get(subjectId);
      if (!keys) bySubject.set(subjectId, (keys = new Set()));
      keys.add(key);
      return value;
    },
    async assignRole(subjectId, role, scope) {
      await inner.assignRole(subjectId, role, scope);
      drop(subjectId, scope);
    },
    async revokeRole(subjectId, role, scope) {
      await inner.revokeRole(subjectId, role, scope);
      drop(subjectId, scope);
    },
    async grant(subjectId, permission, effect, scope) {
      await inner.grant(subjectId, permission, effect, scope);
      drop(subjectId, scope);
    },
    async revokeGrant(subjectId, permission, scope) {
      await inner.revokeGrant(subjectId, permission, scope);
      drop(subjectId, scope);
    },
    listSubjects: (scope) => inner.listSubjects(scope),
    invalidate: drop,
    invalidateAll: () => {
      entries.clear();
      bySubject.clear();
    },
    size: () => entries.size,
  };
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/store-cached.test.ts Expected: PASS — 13 conformance tests plus 6 cache tests

  • Step 5: Commit
git add packages/authz/src/store.ts packages/authz/test/store-cached.test.ts
git commit -m "feat(authz): add caching decorator for PermissionStore"

Task 5: Audit sink

Files:

  • Create: packages/authz/src/audit.ts
  • Test: packages/authz/test/audit.test.ts

Interfaces:

  • Consumes: AuthzScope from ./types.ts

  • Produces: AuthzAuditEvent, AuthzAuditSink, memoryAuditSink(): MemoryAuditSink, consoleAuditSink(): AuthzAuditSink, safeRecord(sink, event): void

  • Step 1: Write the failing test

Create packages/authz/test/audit.test.ts:

import { describe, expect, test } from "bun:test";
import { memoryAuditSink, safeRecord } from "../src/audit.ts";

describe("audit sink", () => {
  test("memoryAuditSink collects events", () => {
    const sink = memoryAuditSink();
    sink.record({ permission: "post:read", allowed: true, at: 1 });
    expect(sink.events).toHaveLength(1);
    expect(sink.events[0]!.permission).toBe("post:read");
  });

  test("safeRecord swallows sink failures", () => {
    const exploding = {
      record() {
        throw new Error("sink is down");
      },
    };
    // Auditing must never break a request.
    expect(() => safeRecord(exploding, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
  });

  test("safeRecord swallows async sink rejections", async () => {
    const rejecting = { record: async () => Promise.reject(new Error("later")) };
    expect(() => safeRecord(rejecting, { permission: "p:x", allowed: false, at: 1 })).not.toThrow();
    await Bun.sleep(1);
  });

  test("safeRecord tolerates an undefined sink", () => {
    expect(() => safeRecord(undefined, { permission: "p:x", allowed: true, at: 1 })).not.toThrow();
  });

  test("safeRecord tolerates a malformed sink", () => {
    const notAFunction = { record: "nope" } as unknown as AuthzAuditSink;
    expect(() =>
      safeRecord(notAFunction, { permission: "p:x", allowed: true, at: 1 }),
    ).not.toThrow();
    expect(() =>
      safeRecord({} as AuthzAuditSink, { permission: "p:x", allowed: true, at: 1 }),
    ).not.toThrow();
  });

  test("memoryAuditSink.clear empties the buffer", () => {
    const sink = memoryAuditSink();
    sink.record({ permission: "p:x", allowed: true, at: 1 });
    sink.clear();
    expect(sink.events).toHaveLength(0);
  });

  test("consoleAuditSink cannot be used to forge a second log line", () => {
    const lines: string[] = [];
    const original = console.info;
    console.info = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      consoleAuditSink().record({
        subjectId: "u1\n[wrnexus:authz] allow admin:everything subject=root",
        permission: "post:read",
        allowed: false,
        reason: "nope\r\ninjected",
        at: 1,
      });
    } finally {
      console.info = original;
    }
    // One event must produce exactly one line, with no embedded newlines.
    expect(lines).toHaveLength(1);
    expect(lines[0]).not.toContain("\n");
    expect(lines[0]).not.toContain("\r");
  });
});

The test file's imports must include consoleAuditSink and the AuthzAuditSink type alongside memoryAuditSink and safeRecord.

  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/audit.test.ts Expected: FAIL — cannot resolve ../src/audit.ts

  • Step 3: Write the implementation

Create packages/authz/src/audit.ts:

import type { AuthzScope } from "./types.ts";

export interface AuthzAuditEvent {
  subjectId?: string;
  scope?: AuthzScope;
  permission: string;
  allowed: boolean;
  reason?: string;
  policy?: string;
  /** Epoch milliseconds. */
  at: number;
}

export interface AuthzAuditSink {
  record(event: AuthzAuditEvent): void | Promise<void>;
}

export interface MemoryAuditSink extends AuthzAuditSink {
  events: AuthzAuditEvent[];
  clear(): void;
}

export function memoryAuditSink(): MemoryAuditSink {
  const events: AuthzAuditEvent[] = [];
  return {
    events,
    record: (event) => void events.push(event),
    clear: () => void events.splice(0, events.length),
  };
}

/**
 * Subject ids, tenant ids, and denial reasons trace back to request input, so
 * a newline in one would forge a second audit line indistinguishable from a
 * real entry. Strip CR/LF and other control characters before interpolating.
 */
function logSafe(value: string): string {
  let out = "";
  for (const character of value) {
    const code = character.codePointAt(0)!;
    // C0 + DEL, plus NEL and the Unicode line/paragraph separators, which some
    // log shippers and JSON consumers also treat as line terminators.
    const isLineBreaking =
      code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029;
    out += isLineBreaking ? " " : character;
  }
  return out;
}

export function consoleAuditSink(): AuthzAuditSink {
  return {
    record(event) {
      const verdict = event.allowed ? "allow" : "deny";
      console.info(
        `[wrnexus:authz] ${verdict} ${logSafe(event.permission)} ` +
          `subject=${logSafe(event.subjectId ?? "anonymous")}` +
          `${event.scope?.tenantId ? ` tenant=${logSafe(event.scope.tenantId)}` : ""}` +
          `${event.reason ? ` reason=${logSafe(event.reason)}` : ""}` +
          `${event.policy ? ` policy=${logSafe(event.policy)}` : ""}`,
      );
    },
  };
}

/** Record without ever letting a sink failure escape into the request path. */
export function safeRecord(sink: AuthzAuditSink | undefined, event: AuthzAuditEvent): void {
  if (!sink) return;
  try {
    const result = sink.record(event);
    if (result instanceof Promise) {
      result.catch((error) => console.warn("[wrnexus:authz] audit sink failed", error));
    }
  } catch (error) {
    console.warn("[wrnexus:authz] audit sink failed", error);
  }
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/audit.test.ts Expected: PASS, 4 tests

  • Step 5: Commit
git add packages/authz/src/audit.ts packages/authz/test/audit.test.ts
git commit -m "feat(authz): add pluggable authorization audit sink"

Task 6: Resolution engine

Files:

  • Create: packages/authz/src/engine.ts
  • Test: packages/authz/test/engine.test.ts

Interfaces:

  • Consumes: AuthzCatalog, AuthzScope, SubjectAssignments from ./types.ts; PermissionStore from ./store.ts; AuthzAuditSink, safeRecord from ./audit.ts; AuthorizationDecision from ./advanced.ts

  • Produces: createAuthzResolver(options: AuthzResolverOptions): AuthzResolver with AuthzResolver { permissionsFor(subjectId, scope?): Promise<Set<string>>; decide(input: DecideInput): Promise<AuthorizationDecision> }, expandRoles(catalog, roles): Set<string>, permissionMatches(granted: Set<string>, permission: string): boolean

  • Step 1: Write the failing test

Create packages/authz/test/engine.test.ts:

import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { memoryAuditSink } from "../src/audit.ts";
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";

const catalog = mergeCatalogs([
  {
    source: "test.ts",
    module: defineAuthz({
      permissions: {
        "post:read": { public: true },
        "post:write": {},
        "post:delete": { risk: "high" },
        "post:comment:delete": {},
      },
      roles: {
        editor: ["post:*"],
        moderator: ["post:comment:*"],
        admin: ["role:editor", "post:delete"],
        cyclic: ["role:cyclic", "post:read"],
      },
      policies: {
        ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
          resource?.authorId === subject?.id
            ? { allowed: true }
            : { allowed: false, reason: "not the author", policy: "ownsPost" },
        explodes: async () => {
          throw new Error("policy blew up");
        },
      },
      bindings: { "post:write": ["ownsPost"] },
    }),
  },
]);

const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
  store,
  audit,
  resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
});

describe("expandRoles", () => {
  test("expands wildcards and role inheritance", () => {
    expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
  });
  test("terminates on cyclic inheritance", () => {
    expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
  });
});

describe("permissionMatches", () => {
  test("matches exact, root wildcard, and every namespace depth", () => {
    expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
    expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
    expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
    expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
  });
});

describe("createAuthzResolver.decide", () => {
  test("allows a public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:read" });
    expect(result.allowed).toBe(true);
  });

  test("denies a non-public permission for an anonymous subject", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: null, permission: "post:delete" });
    expect(result.allowed).toBe(false);
  });

  test("allows via a role-derived wildcard", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "moderator");
    const result = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:comment:delete",
    });
    expect(result.allowed).toBe(true);
  });

  test("an explicit deny beats a role and beats '*'", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:delete", "deny");
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/explicit deny/i);
  });

  test("a bound policy can deny a permission the role grants", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    const denied = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "someone-else" },
    });
    expect(denied.allowed).toBe(false);
    expect(denied.policy).toBe("ownsPost");

    const allowed = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
    });
    expect(allowed.allowed).toBe(true);
  });

  test("a throwing policy denies rather than escaping", async () => {
    const throwing = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "x:go": {} },
          policies: {
            explodes: async () => {
              throw new Error("boom");
            },
          },
          bindings: { "x:go": ["explodes"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
    expect(result.allowed).toBe(false);
  });

  test("a store failure denies and does not throw", async () => {
    const broken = {
      ...memoryPermissionStore(),
      assignmentsFor: async () => {
        throw new Error("db down");
      },
    };
    const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(result.allowed).toBe(false);
  });

  test("an unregistered permission denies when strict is off", async () => {
    const { resolver } = make();
    const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
    expect(result.allowed).toBe(false);
    expect(result.reason).toMatch(/not registered/i);
  });

  test("an unregistered permission throws when strict is on", async () => {
    const resolver = createAuthzResolver({
      catalog,
      store: memoryPermissionStore(),
      strict: true,
    });
    await expect(
      resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
    ).rejects.toThrow(/ghost:perm/);
  });

  test("denials are audited and allows are not, by default", async () => {
    const { store, audit, resolver } = make();
    // moderator, NOT editor: editor holds "post:*", which legitimately grants
    // post:delete, so that call would be an allow and nothing would be audited.
    await store.assignRole("u1", "moderator");
    await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
    await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(false);
  });

  test("auditAllows records both verdicts", async () => {
    const store = memoryPermissionStore();
    const audit = memoryAuditSink();
    const resolver = createAuthzResolver({
      catalog,
      store,
      audit,
      strict: false,
      auditAllows: true,
    });
    await resolver.decide({ subject: null, permission: "post:read" });
    expect(audit.events).toHaveLength(1);
    expect(audit.events[0]!.allowed).toBe(true);
  });

  test("tenant scope selects the right assignments", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    const inside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t1" },
    });
    const outside = await resolver.decide({
      subject: { id: "u1" },
      permission: "post:write",
      resource: { authorId: "u1" },
      scope: { tenantId: "t2" },
    });
    expect(inside.allowed).toBe(true);
    expect(outside.allowed).toBe(false);
  });
});

describe("createAuthzResolver fail-closed regressions", () => {
  const guarded = mergeCatalogs([
    {
      source: "guarded.ts",
      module: defineAuthz({
        permissions: { "feed:view": { public: true }, "x:go": {} },
        policies: {
          never: async () => ({ allowed: false, reason: "always no", policy: "never" }),
          truthy: async () => ({ allowed: "yes" }) as never,
        },
        bindings: { "feed:view": ["never"] },
      }),
    },
  ]);

  test("a public permission still runs its bound policies for anonymous callers", async () => {
    // The least-trusted caller must not receive the weakest evaluation:
    // `public` relaxes the identity requirement, never the policy requirement.
    const resolver = createAuthzResolver({
      catalog: guarded,
      store: memoryPermissionStore(),
      strict: false,
    });
    const anonymous = await resolver.decide({ subject: null, permission: "feed:view" });
    expect(anonymous.allowed).toBe(false);
    expect(anonymous.policy).toBe("never");
  });

  test("a policy returning a truthy non-boolean denies", async () => {
    const catalog = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "x:go": {} },
          policies: { truthy: async () => ({ allowed: "yes" }) as never },
          bindings: { "x:go": ["truthy"] },
        }),
      },
    ]);
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog, store, strict: false });
    expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
      false,
    );
  });

  test("a binding naming a policy the catalog lacks denies rather than skipping", async () => {
    // Hand-built catalog: mergeCatalogs would reject this, but the resolver
    // accepts any AuthzCatalog and must not grant what the policy guarded.
    const broken = {
      permissions: new Map([["x:go", {}]]),
      roles: new Map(),
      policies: new Map(),
      attributes: new Map(),
      bindings: new Map([["x:go", ["ghost"]]]),
    } as unknown as Parameters<typeof createAuthzResolver>[0]["catalog"];
    const store = memoryPermissionStore();
    await store.grant("u1", "x:go", "allow");
    const resolver = createAuthzResolver({ catalog: broken, store, strict: false });
    expect((await resolver.decide({ subject: { id: "u1" }, permission: "x:go" })).allowed).toBe(
      false,
    );
  });

  test("a wildcard deny blocks the whole namespace", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "admin");
    await store.grant("u1", "post:*", "deny");
    expect(
      (await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" })).allowed,
    ).toBe(false);
  });

  test("permissionsFor omits denied permissions", async () => {
    const { store, resolver } = make();
    await store.assignRole("u1", "editor");
    await store.grant("u1", "post:*", "deny");
    const effective = await resolver.permissionsFor("u1");
    // The obvious composition must agree with decide().
    expect(permissionMatches(effective, "post:write")).toBe(false);
  });

  test("a non-string or empty subject id denies instead of falling back to anonymous", async () => {
    const { resolver } = make();
    for (const id of [0, "", null, 123, {}]) {
      const result = await resolver.decide({
        subject: { id } as never,
        permission: "post:read", // public — must still not be reached this way
      });
      if (id === null) continue; // null is genuinely anonymous
      expect(result.allowed).toBe(false);
    }
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/engine.test.ts Expected: FAIL — cannot resolve ../src/engine.ts

  • Step 3: Write the implementation

Create packages/authz/src/engine.ts:

import type { AuthorizationDecision } from "./advanced.ts";
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
import type { PermissionStore } from "./store.ts";
import type { AuthzCatalog, AuthzScope } from "./types.ts";

export interface AuthzResolverOptions {
  catalog: AuthzCatalog;
  store: PermissionStore;
  audit?: AuthzAuditSink;
  /**
   * Throw on an unregistered permission instead of denying. Defaults to true
   * outside production, so typos surface during development.
   */
  strict?: boolean;
  /** Record allows as well as denies. Off by default to bound write volume. */
  auditAllows?: boolean;
}

export interface DecideInput {
  subject: { id?: string; [key: string]: unknown } | null | undefined;
  permission: string;
  resource?: unknown;
  scope?: AuthzScope;
}

export interface AuthzResolver {
  /**
   * Effective permissions with denied entries removed — for coarse gating such
   * as hiding a menu section.
   *
   * NOT authoritative. A set of strings cannot express "everything under
   * `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
   * not representable here: the set still contains `post:*` while `decide()`
   * correctly refuses `post:delete`. Gate individual actions with `decide()`
   * (or `can()` / `filterCan()`), never by matching against this set.
   */
  permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
  decide(input: DecideInput): Promise<AuthorizationDecision>;
}

/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
  const out = new Set<string>();
  const seen = new Set<string>();
  const walk = (role: string) => {
    if (seen.has(role)) return;
    seen.add(role);
    for (const entry of catalog.roles.get(role) ?? []) {
      if (entry.startsWith("role:")) walk(entry.slice(5));
      else out.add(entry);
    }
  };
  for (const role of roles) walk(role);
  return out;
}

/**
 * Exact match, root wildcard, or a namespace wildcard at any depth.
 *
 * Do NOT gate access by matching against `permissionsFor()`'s result — that set
 * cannot represent a narrow deny beneath a broad grant, so the composition
 * returns true where `decide()` refuses. Use `decide()` / `can()` instead.
 */
export function permissionMatches(granted: Set<string>, permission: string): boolean {
  if (granted.has("*") || granted.has(permission)) return true;
  for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
    if (granted.has(`${permission.slice(0, at)}:*`)) return true;
  }
  return false;
}

/**
 * True if any entry in the deny list covers `permission`. Denies honour the
 * same depth-aware wildcards as grants, so denying "post:*" blocks
 * post:comment:delete rather than being accepted and silently doing nothing.
 */
export function deniedBy(denies: readonly string[], permission: string): boolean {
  return denies.length ? permissionMatches(new Set(denies), permission) : false;
}

function isProduction(): boolean {
  return (process.env.NODE_ENV ?? "development") === "production";
}

export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
  const { catalog, store, audit } = options;
  const strict = options.strict ?? !isProduction();

  /**
   * Single source of truth for "what does this subject hold?". Returns the raw
   * assignments alongside the effective set, because `decide` reports on the
   * deny that blocked it. Do NOT duplicate this logic in either caller.
   */
  const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
    const assignments = await store.assignmentsFor(subjectId, scope);
    const granted = expandRoles(catalog, assignments.roles);
    for (const grant of assignments.grants) granted.add(grant);
    return { assignments, granted };
  };

  /**
   * Effective permissions, denies already removed. Callers compose this with
   * `permissionMatches` to gate menus and admin UI, so it must not report a
   * permission that `decide` would refuse.
   */
  const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
    const { assignments, granted } = await loadEffective(subjectId, scope);
    if (!assignments.denies.length) return granted;
    // Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
    // allocations on a per-request path whose input size an operator controls.
    const denySet = new Set(assignments.denies);
    const effective = new Set<string>();
    for (const entry of granted) {
      // A wildcard grant survives only if nothing denies it outright.
      if (!permissionMatches(denySet, entry)) effective.add(entry);
    }
    return effective;
  };

  const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
    if (!result.allowed || options.auditAllows) {
      safeRecord(audit, {
        subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
        scope: input.scope,
        permission: input.permission,
        allowed: result.allowed,
        reason: result.reason,
        policy: result.policy,
        at: Date.now(),
      });
    }
    return result;
  };

  /**
   * Run every policy bound to a permission. Returns a denial, or null to allow.
   * Anonymous callers run this too: `public` relaxes the identity requirement,
   * never the policy requirement.
   */
  const runPolicies = async (
    input: DecideInput,
    permission: string,
  ): Promise<AuthorizationDecision | null> => {
    for (const name of catalog.bindings.get(permission) ?? []) {
      const policy = catalog.policies.get(name);
      if (!policy) {
        // A binding naming a policy the catalog lacks must deny, not skip:
        // silently ignoring it would grant whatever the policy guarded.
        console.error(
          `[wrnexus:authz] binding for '${permission}' names unknown policy '${name}'; denying`,
        );
        return { allowed: false, reason: "Policy unavailable", policy: name };
      }
      try {
        const verdict = await (
          policy as unknown as (
            s: unknown,
            r: unknown,
          ) => AuthorizationDecision | Promise<AuthorizationDecision>
        )(input.subject, input.resource);
        // Identity check, not truthiness: {allowed: "yes"} must not grant.
        if (verdict?.allowed !== true) {
          return {
            allowed: false,
            reason: verdict?.reason ?? "Policy denied access",
            policy: verdict?.policy ?? name,
          };
        }
      } catch (error) {
        console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
        return { allowed: false, reason: "Policy error", policy: name };
      }
    }
    return null;
  };

  return {
    permissionsFor,

    async decide(input) {
      const { subject, permission, scope } = input;
      const meta = catalog.permissions.get(permission);

      if (!meta) {
        if (strict) {
          throw new Error(
            `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
              `Declare it with defineAuthz() in app/authz/.`,
          );
        }
        return finish(input, {
          allowed: false,
          reason: `Permission '${permission}' is not registered`,
        });
      }

      // Only a non-empty string identifies a subject. A numeric id of 0 or a
      // non-string id must not fall through to the anonymous path, and must
      // never reach the store as a lookup key.
      const rawId: unknown = subject?.id;
      const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
      if (rawId !== undefined && rawId !== null && subjectId === undefined) {
        console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
        return finish(input, { allowed: false, reason: "Invalid subject" });
      }

      if (!subjectId) {
        if (!meta.public) {
          return finish(input, { allowed: false, reason: "Authentication required" });
        }
        const denied = await runPolicies(input, permission);
        return finish(input, denied ?? { allowed: true, reason: "public permission" });
      }

      let assignments;
      let granted: Set<string>;
      try {
        ({ assignments, granted } = await loadEffective(subjectId, scope));
      } catch (error) {
        console.error("[wrnexus:authz] permission store failed; denying", error);
        return finish(input, { allowed: false, reason: "Authorization store unavailable" });
      }

      // 1. Explicit deny wins over everything, including "*". Wildcards are
      //    honoured here exactly as they are for grants, so denying "post:*"
      //    blocks post:delete rather than silently doing nothing.
      if (deniedBy(assignments.denies, permission)) {
        return finish(input, { allowed: false, reason: "explicit deny" });
      }

      // 2. Must hold the permission at all.
      if (!meta.public && !permissionMatches(granted, permission)) {
        return finish(input, { allowed: false, reason: "Missing permission" });
      }

      // 3. Every bound policy must pass.
      const denied = await runPolicies(input, permission);
      return finish(input, denied ?? { allowed: true });
    },
  };
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/engine.test.ts Expected: PASS, 15 tests

  • Step 5: Commit
git add packages/authz/src/engine.ts packages/authz/test/engine.test.ts
git commit -m "feat(authz): add resolution engine with deny-wins precedence and fail-closed errors"

Task 7: Middleware, can(), and guards

Files:

  • Create: packages/authz/src/middleware.ts
  • Test: packages/authz/test/middleware.test.ts

Interfaces:

  • Consumes: createAuthzResolver, AuthzResolverOptions, AuthzResolver from ./engine.ts; Context, Middleware types from @wrnexus/core

  • Produces: AUTHZ_LOCALS_KEY, authzMiddleware(options: AuthzResolverOptions): Middleware, decideFor(ctx, permission, resource?): Promise<AuthorizationDecision>, can(ctx, permission, resource?): Promise<boolean>, guardPermission(permission, getResource?): Middleware, filterCan<T>(ctx, permission, items): Promise<T[]>

  • Step 1: Write the failing test

Create packages/authz/test/middleware.test.ts:

import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { authzMiddleware, can, filterCan, guardPermission } from "../src/middleware.ts";

const catalog = mergeCatalogs([
  {
    source: "t.ts",
    module: defineAuthz({
      permissions: { "post:read": { public: true }, "post:write": {}, "post:delete": {} },
      roles: { editor: ["post:write"] },
      policies: {
        ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
          r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
      },
      bindings: { "post:delete": ["ownsPost"] },
    }),
  },
]);

/** Minimal Context stand-in; the middleware only touches user, tenant, locals. */
function makeCtx(user: unknown, tenantId?: string): Context {
  return {
    user,
    tenant: tenantId ? { id: tenantId } : undefined,
    locals: {},
    url: new URL("http://localhost/x"),
    req: new Request("http://localhost/x"),
  } as unknown as Context;
}

const withMiddleware = async (ctx: Context, store = memoryPermissionStore()) => {
  await authzMiddleware({ catalog, store, strict: false })(ctx, async () => new Response("ok"));
  return store;
};

describe("authzMiddleware + can", () => {
  test("can() resolves through the middleware-installed resolver", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
    expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(false);
  });

  test("can() throws a clear setup error without the middleware", async () => {
    const ctx = makeCtx({ id: "u1" });
    await expect(can(ctx, "post:read")).rejects.toThrow(/authzMiddleware/);
  });

  test("results are memoised per request", async () => {
    const inner = memoryPermissionStore();
    let reads = 0;
    const counting = {
      ...inner,
      assignmentsFor: (id: string, scope?: { tenantId?: string }) => {
        reads++;
        return inner.assignmentsFor(id, scope);
      },
    };
    const ctx = makeCtx({ id: "u1" });
    await authzMiddleware({ catalog, store: counting, strict: false })(
      ctx,
      async () => new Response("ok"),
    );
    await can(ctx, "post:write");
    await can(ctx, "post:write");
    expect(reads).toBe(1);
  });

  test("memoisation keys on the resource, not just the permission", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:delete", { authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { authorId: "other" })).toBe(false);
  });

  test("the tenant on the context becomes the scope", async () => {
    const ctx = makeCtx({ id: "u1" }, "t1");
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
  });
});

describe("guardPermission", () => {
  test("calls next when allowed", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor");
    await withMiddleware(ctx, store);
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(await res.text()).toBe("passed");
  });

  test("returns 403 without leaking the reason by default", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    const body = (await res.json()) as Record<string, unknown>;
    expect(body).toEqual({ ok: false, error: "Forbidden" });
  });

  test("exposeReason opts into diagnostics", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const res = await guardPermission("post:write", { exposeReason: true })(
      ctx,
      async () => new Response("passed"),
    );
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.reason).toBe("Missing permission");
  });

  test("getResource feeds the bound policy", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    const guard = guardPermission("post:delete", { getResource: () => ({ authorId: "u1" }) });
    const res = await guard(ctx, async () => new Response("passed"));
    expect(await res.text()).toBe("passed");
  });
});

describe("filterCan", () => {
  test("keeps only the items the subject may act on", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    const posts = [{ authorId: "u1" }, { authorId: "other" }, { authorId: "u1" }];
    expect(await filterCan(ctx, "post:delete", posts)).toHaveLength(2);
  });

  test("does not leak rows the memo cannot serialise", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    // BigInt columns and circular references are ordinary in ORM rows. A memo
    // that serialises resources funnels all of these into one shared key and
    // returns the first verdict for every later row.
    const circular: Record<string, unknown> = { authorId: "other" };
    circular.self = circular;
    const rows = [{ authorId: "u1", views: 10n }, { authorId: "other", views: 11n }, circular];
    expect(await filterCan(ctx, "post:delete", rows)).toEqual([rows[0]]);
  });

  test("returns an empty array for no items", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    expect(await filterCan(ctx, "post:delete", [])).toEqual([]);
  });
});

describe("per-request memo isolation", () => {
  test("distinct resources are never cross-authorized", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    // Same id, different owner; object ids; primitives of different type.
    expect(await can(ctx, "post:delete", { id: 7, authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: "7", authorId: "other" })).toBe(false);
    expect(await can(ctx, "post:delete", { id: { t: "A" }, authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: { t: "B" }, authorId: "other" })).toBe(false);
  });

  test("a changed row is not authorized against the stale copy", async () => {
    const ctx = makeCtx({ id: "u1" });
    const store = memoryPermissionStore();
    await store.grant("u1", "post:delete", "allow");
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:delete", { id: "p1", authorId: "u1" })).toBe(true);
    expect(await can(ctx, "post:delete", { id: "p1", authorId: "someone-else" })).toBe(false);
  });

  test("switching tenant mid-request re-evaluates", async () => {
    const ctx = makeCtx({ id: "u1" }, "t1");
    const store = memoryPermissionStore();
    await store.assignRole("u1", "editor", { tenantId: "t1" });
    await withMiddleware(ctx, store);
    expect(await can(ctx, "post:write")).toBe(true);
    (ctx as { tenant?: { id: string } }).tenant = { id: "t2" };
    // Scope is read at decision time, so the t1 grant must not carry over.
    expect(await can(ctx, "post:write")).toBe(false);
  });
});

describe("guardPermission hardening", () => {
  test("throws the setup error rather than calling next", async () => {
    const ctx = makeCtx({ id: "u1" }); // no authzMiddleware
    let reached = false;
    await expect(
      guardPermission("post:write")(ctx, async () => {
        reached = true;
        return new Response("passed");
      }),
    ).rejects.toThrow(/authzMiddleware/);
    expect(reached).toBe(false);
  });

  test("a throwing getResource denies instead of 500ing", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    const guard = guardPermission("post:delete", {
      getResource: () => {
        throw new Error("SELECT * FROM posts WHERE id=$1 failed");
      },
    });
    const res = await guard(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    const body = await res.text();
    expect(body).not.toContain("SELECT");
  });

  test("redirectTo applies to page requests but not API requests", async () => {
    const page = makeCtx({ id: "u1" });
    await withMiddleware(page);
    const redirected = await guardPermission("post:write", { redirectTo: "/login" })(
      page,
      async () => new Response("passed"),
    );
    expect(redirected.status).toBe(303);

    const api = makeCtx({ id: "u1" });
    (api as { url: URL }).url = new URL("http://localhost/api/posts");
    await withMiddleware(api);
    const json = await guardPermission("post:write", { redirectTo: "/login" })(
      api,
      async () => new Response("passed"),
    );
    // An API caller must see the denial, not follow a redirect into a 200.
    expect(json.status).toBe(403);
  });

  test("an off-site redirectTo is refused", async () => {
    const ctx = makeCtx({ id: "u1" });
    await withMiddleware(ctx);
    for (const target of ["https://evil.example.com/harvest", "//evil.example.com"]) {
      const res = await guardPermission("post:write", { redirectTo: target })(
        ctx,
        async () => new Response("passed"),
      );
      expect(res.status).toBe(403);
    }
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/middleware.test.ts Expected: FAIL — cannot resolve ../src/middleware.ts

  • Step 3: Write the implementation

Create packages/authz/src/middleware.ts:

import type { Context, Middleware } from "@wrnexus/core";
import type { AuthorizationDecision } from "./advanced.ts";
import { createAuthzResolver, type AuthzResolver, type AuthzResolverOptions } from "./engine.ts";
import type { AuthzScope } from "./types.ts";

/**
 * `can` is deliberately not a Context member: @wrnexus/core must not depend on
 * @wrnexus/authz. The per-request resolver lives here instead.
 */
export const AUTHZ_LOCALS_KEY = "_authz";

interface RequestAuthz {
  resolver: AuthzResolver;
  /** Memo for object resources, keyed by identity so two rows never collide. */
  byRef: WeakMap<object, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for symbol resources, which also carry identity. */
  bySymbol: Map<symbol, Map<string, Promise<AuthorizationDecision>>>;
  /** Memo for primitive and absent resources. */
  byValue: Map<string, Promise<AuthorizationDecision>>;
}

function readAuthz(ctx: Context): RequestAuthz {
  const value = ctx.locals[AUTHZ_LOCALS_KEY] as RequestAuthz | undefined;
  if (!value) {
    throw new Error(
      "WRN-AUTHZ-SETUP: authzMiddleware() is not registered for this request. " +
        "Add it to app/middleware before calling can()/guardPermission().",
    );
  }
  return value;
}

/**
 * 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.
 */
function currentScope(ctx: Context): AuthzScope | undefined {
  const tenantId = ctx.tenant?.id;
  return typeof tenantId === "string" && tenantId !== "" ? { tenantId } : undefined;
}

/** Install the per-request resolver. Register after sessionAuth and tenantMiddleware. */
export function authzMiddleware(options: AuthzResolverOptions): Middleware {
  const resolver = createAuthzResolver(options);
  return (ctx, next) => {
    ctx.locals[AUTHZ_LOCALS_KEY] = {
      resolver,
      byRef: new WeakMap(),
      bySymbol: new Map(),
      byValue: new Map(),
    } satisfies RequestAuthz;
    return next();
  };
}

export function decideFor(
  ctx: Context,
  permission: string,
  resource?: unknown,
): Promise<AuthorizationDecision> {
  const request = readAuthz(ctx);
  const scope = currentScope(ctx);
  // Scope is part of the key: the same permission decides differently per tenant.
  // Subject and scope are both part of the key. A request that reassigns
  // ctx.user (impersonation, step-up auth, session revocation) or ctx.tenant
  // must not be served the previous principal's verdict from the memo.
  const subjectId = (ctx.user as { id?: unknown } | null | undefined)?.id;
  const key = JSON.stringify([
    scope?.tenantId ?? "",
    permission,
    typeof subjectId,
    String(subjectId),
  ]);

  const run = () =>
    request.resolver.decide({
      subject: ctx.user as { id?: string } | null | undefined,
      permission,
      resource,
      scope,
    });

  // Object resources memo by IDENTITY. Serialising them would let two distinct
  // rows share a key and cross-authorize, and unserialisable ones (circular
  // refs, BigInt fields, throwing getters) would all collapse into one bucket.
  // Symbols carry identity that String() erases, so they memo by identity too.
  // They are held in a plain Map rather than the WeakMap: the memo is discarded
  // with the request, so there is nothing to leak.
  if (typeof resource === "symbol") {
    let perSymbol = request.bySymbol.get(resource);
    if (!perSymbol) request.bySymbol.set(resource, (perSymbol = new Map()));
    const cached = perSymbol.get(key);
    if (cached) return cached;
    const pending = run();
    perSymbol.set(key, pending);
    return pending;
  }

  if (resource !== null && (typeof resource === "object" || typeof resource === "function")) {
    let perResource = request.byRef.get(resource as object);
    if (!perResource) request.byRef.set(resource as object, (perResource = new Map()));
    const cached = perResource.get(key);
    if (cached) return cached;
    const pending = run();
    perResource.set(key, pending);
    return pending;
  }

  // typeof is part of the key so 7 and "7" are not the same resource, and
  // -0 keeps its sign because String(-0) is "0".
  const rendered = Object.is(resource, -0) ? "-0" : String(resource);
  const valueKey = JSON.stringify([key, typeof resource, rendered]);
  const cached = request.byValue.get(valueKey);
  if (cached) return cached;
  const pending = run();
  request.byValue.set(valueKey, pending);
  return pending;
}

export async function can(ctx: Context, permission: string, resource?: unknown): Promise<boolean> {
  return (await decideFor(ctx, permission, resource)).allowed;
}

export interface GuardOptions {
  /** Load the resource a bound policy needs. */
  getResource?: (ctx: Context) => unknown;
  /** Include reason and policy name in the 403 body. Off by default. */
  exposeReason?: boolean;
  /** Redirect page requests here instead of returning 403. Must be a local path. */
  redirectTo?: string;
}

/** Same rule requireAuth uses, replicated because authz may only import TYPES from core. */
function wantsJson(ctx: Context): boolean {
  if (ctx.url.pathname.startsWith("/api/")) return true;
  const accept = ctx.req.headers.get("accept") ?? "";
  return accept.includes("application/json") && !accept.includes("text/html");
}

/**
 * Header values must be Latin-1, so a localized path would otherwise throw
 * inside `new Response` and 500 on a denial path. Encode ONLY the codepoints
 * that cannot be sent: encodeURI would also escape "%", corrupting a target
 * that already carries a percent-encoded return path.
 */
function headerSafePath(value: string): string {
  let out = "";
  for (const character of value) {
    out += character.codePointAt(0)! <= 0x7f ? character : encodeURIComponent(character);
  }
  return out;
}

/** Reject anything that could navigate off-site or inject a header. */
function isLocalPath(value: string): boolean {
  if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return false;
  for (const character of value) {
    const code = character.codePointAt(0)!;
    if (code < 0x20 || code === 0x7f) return false;
  }
  return true;
}

/**
 * Guard a route on a registered permission. Named `guardPermission` because
 * `requirePermission(rbac, permission)` already exists with a different shape.
 */
export function guardPermission(permission: string, options: GuardOptions = {}): Middleware {
  return async (ctx, next) => {
    let resource: unknown;
    if (options.getResource) {
      try {
        resource = await options.getResource(ctx);
      } catch (error) {
        // Loading the resource failed, so the policy cannot be evaluated. Deny
        // rather than 500 — and never leak the loader's message to the client.
        console.error(`[wrnexus:authz] getResource for '${permission}' threw; denying`, error);
        return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
      }
    }
    const result = await decideFor(ctx, permission, resource);
    if (result.allowed) return next();

    if (options.redirectTo && !wantsJson(ctx)) {
      if (!isLocalPath(options.redirectTo)) {
        // JSON-encode: this branch exists precisely for values containing
        // CR/LF, which would otherwise forge a second log line.
        console.error(
          `[wrnexus:authz] redirectTo must be a local path, got ${JSON.stringify(options.redirectTo)}; denying`,
        );
      } else {
        return new Response(null, {
          status: 303,
          headers: {
            location: headerSafePath(options.redirectTo),
            "cache-control": "private, no-store",
          },
        });
      }
    }
    return Response.json(
      options.exposeReason
        ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
        : { ok: false, error: "Forbidden" },
      { status: 403, headers: { "cache-control": "private, no-store" } },
    );
  };
}

/** Keep only the items the current subject may act on. */
export async function filterCan<T>(
  ctx: Context,
  permission: string,
  items: readonly T[],
): Promise<T[]> {
  const verdicts = await Promise.all(
    items.map(async (item) => ({ item, allowed: await can(ctx, permission, item) })),
  );
  return verdicts.filter((entry) => entry.allowed).map((entry) => entry.item);
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/middleware.test.ts Expected: PASS, 10 tests

  • Step 5: Commit
git add packages/authz/src/middleware.ts packages/authz/test/middleware.test.ts
git commit -m "feat(authz): add request middleware, can(), and guardPermission"

Task 8: Stop authorizeDecision leaking policy internals

Files:

  • Modify: packages/authz/src/advanced.ts:72-83
  • Test: packages/authz/test/authz.test.ts (append)

Interfaces:

  • Consumes: AuthorizationDecision from ./advanced.ts

  • Produces: authorizeDecision(evaluate, options?: { exposeReason?: boolean }): Middleware — behaviour change, body is now { ok: false, error: "Forbidden" } unless opted in

  • Step 1: Write the failing test

Append to packages/authz/test/authz.test.ts:

describe("authorizeDecision disclosure", () => {
  const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context;
  const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" });

  test("does not leak reason or policy by default", async () => {
    const res = await authorizeDecision(denier)(ctx, async () => new Response("ok"));
    expect(res.status).toBe(403);
    expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
  });

  test("exposeReason opts back in", async () => {
    const res = await authorizeDecision(denier, { exposeReason: true })(
      ctx,
      async () => new Response("ok"),
    );
    const body = (await res.json()) as Record<string, unknown>;
    expect(body.reason).toBe("secret internal rule");
    expect(body.policy).toBe("isVip");
  });

  test("still calls next when allowed", async () => {
    const res = await authorizeDecision(async () => ({ allowed: true }))(
      ctx,
      async () => new Response("passed"),
    );
    expect(await res.text()).toBe("passed");
  });
});

Add authorizeDecision to the file's existing import from ../src/index.ts if it is not already imported.

  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/authz.test.ts Expected: FAIL — the default response still contains reason

  • Step 3: Modify packages/authz/src/advanced.ts

Replace the authorizeDecision function with:

export interface AuthorizeDecisionOptions {
  /**
   * Include `reason` and `policy` in the 403 body. Off by default: policy
   * names describe internal authorization structure and should not reach an
   * unauthenticated caller.
   */
  exposeReason?: boolean;
}

export function authorizeDecision(
  evaluate: (ctx: Context) => AuthorizationDecision | Promise<AuthorizationDecision>,
  options: AuthorizeDecisionOptions = {},
): Middleware {
  return async (ctx, next) => {
    const result = await evaluate(ctx);
    if (result.allowed) return next();
    return Response.json(
      options.exposeReason
        ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }
        : { ok: false, error: "Forbidden" },
      { status: 403 },
    );
  };
}
  • Step 4: Run test to verify it passes

Run: bun test packages/authz/test/authz.test.ts Expected: PASS

  • Step 5: Commit
git add packages/authz/src/advanced.ts packages/authz/test/authz.test.ts
git commit -m "fix(authz): stop authorizeDecision leaking policy names in 403 bodies"

Task 9: Export the new surface

Files:

  • Modify: packages/authz/src/index.ts (append to the existing re-export block)
  • Modify: docs/public-api-0.8.json (regenerated)
  • Test: packages/authz/test/exports.test.ts

Interfaces:

  • Consumes: everything from Tasks 1-8

  • Produces: the public @wrnexus/authz surface

  • Step 1: Write the failing test

Create packages/authz/test/exports.test.ts:

import { describe, expect, test } from "bun:test";
import * as authz from "../src/index.ts";

describe("@wrnexus/authz exports", () => {
  test("keeps the pre-existing surface", () => {
    for (const name of [
      "defineRbac",
      "hasRole",
      "any",
      "all",
      "attr",
      "authorize",
      "requireRole",
      "requirePermission",
      "allow",
      "deny",
      "decision",
      "owner",
      "anyDecision",
      "allDecisions",
      "authorizeDecision",
      "filterAuthorized",
    ]) {
      expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
    }
  });

  test("adds the registry, store, engine, and middleware surface", () => {
    for (const name of [
      "defineAuthz",
      "mergeCatalogs",
      "emptyCatalog",
      "memoryPermissionStore",
      "cachedPermissionStore",
      "memoryAuditSink",
      "consoleAuditSink",
      "createAuthzResolver",
      "expandRoles",
      "permissionMatches",
      "scopeKey",
      "safeRecord",
      "deniedBy",
      "authzMiddleware",
      "can",
      "decideFor",
      "guardPermission",
      "filterCan",
    ]) {
      expect(typeof (authz as Record<string, unknown>)[name]).toBe("function");
    }
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/exports.test.ts Expected: FAIL — defineAuthz is undefined

  • Step 3: Append to packages/authz/src/index.ts
export { defineAuthz } from "./registry.ts";
export { mergeCatalogs, emptyCatalog } from "./catalog.ts";
export type { CatalogSource } from "./catalog.ts";
export { memoryPermissionStore, cachedPermissionStore, scopeKey } from "./store.ts";
export type { PermissionStore, CachedPermissionStore, CacheOptions, GrantEffect } from "./store.ts";
export { memoryAuditSink, consoleAuditSink, safeRecord } from "./audit.ts";
export type { AuthzAuditEvent, AuthzAuditSink, MemoryAuditSink } from "./audit.ts";
export { createAuthzResolver, expandRoles, permissionMatches, deniedBy } from "./engine.ts";
export type { AuthzResolver, AuthzResolverOptions, DecideInput } from "./engine.ts";
export {
  authzMiddleware,
  can,
  decideFor,
  guardPermission,
  filterCan,
  AUTHZ_LOCALS_KEY,
} from "./middleware.ts";
export type { GuardOptions } from "./middleware.ts";
export type {
  AuthzScope,
  AuthzCatalog,
  AuthzModule,
  AttributeMeta,
  PermissionMeta,
  SubjectAssignments,
} from "./types.ts";
export type { AuthorizeDecisionOptions } from "./advanced.ts";
  • Step 4: Run tests and regenerate the API baseline

Run: bun test packages/authz && bun run generate:public-api && bun run check:public-api Expected: tests PASS; baseline regenerates; check reports a match

  • Step 5: Commit
git add packages/authz/src/index.ts packages/authz/test/exports.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): export registry, store, engine, and middleware surface"

Task 10: Router discovery of app/authz

Files:

  • Modify: packages/router/src/index.ts:273-294 (alongside the existing schema scan)
  • Test: packages/router/test/authz-discovery.test.ts

Interfaces:

  • Consumes: scanDir, isSafeIslandName, ComponentRef already in packages/router/src/index.ts

  • Produces: Router.authz: ComponentRef[]

  • Step 1: Write the failing test

Create packages/router/test/authz-discovery.test.ts:

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([]);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/router/test/authz-discovery.test.ts Expected: FAIL — router.authz is undefined

  • Step 3: Modify packages/router/src/index.ts

Add to the Router interface, next to schemas:

  /** Authorization declarations (`app/authz/<name>.ts`) merged into the catalog. */
  authz: ComponentRef[];

Add the scan immediately after the existing schemas loop:

// Authorization declarations: app/authz/<name>.{ts,js}, each default-exporting
// a defineAuthz() module. Merged into the catalog at boot.
const authz: ComponentRef[] = [];
// scanDir's extension allow-list is route-oriented; passing [".js"] here keeps
// .js out of app/pages scanning, where it would leak into route URLs.
for (const f of scanDir(join(appDir, "authz"), [".js"])) {
  if (!/\.(ts|js)$/.test(f.file)) continue;
  // Generated type files (permissions.gen.ts) live here too. Skip them quietly:
  // they export types only, and isSafeIslandName would otherwise reject the dot
  // and warn on every boot.
  if (/[.]gen[.](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 });
}

Add authz, to the returned object, next to schemas,.

  • Step 4: Run test to verify it passes

Run: bun test packages/router Expected: PASS — the new file plus existing router tests

  • Step 5: Commit
git add packages/router/src/index.ts packages/router/test/authz-discovery.test.ts
git commit -m "feat(router): discover app/authz declarations"

Task 11: Database store adapter

Files:

  • Create: packages/authz/src/migrations.ts
  • Create: packages/authz/src/db.ts
  • Modify: packages/authz/package.json (add ./db export)
  • Test: packages/authz/test/store-db.test.ts

Interfaces:

  • Consumes: PermissionStore, scopeKey from ./store.ts; Db type from @wrnexus/db; Dialect from @wrnexus/db

  • Produces: authzMigrationSql(dialect: Dialect): { up: string; down: string }, dbPermissionStore(db: Db): PermissionStore, ensureAuthzTables(db: Db, dialect?: Dialect): Promise<void>

  • Step 1: Write the failing test

Create packages/authz/test/store-db.test.ts:

import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import { runStoreConformance } from "./store-conformance.ts";

// The db adapter must satisfy exactly the same contract as the memory one.
runStoreConformance("sqlite", async () => {
  const db = createDb(sqlite(":memory:"));
  await ensureAuthzTables(db, "sqlite");
  return dbPermissionStore(db);
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/store-db.test.ts Expected: FAIL — cannot resolve ../src/db.ts

  • Step 3: Write the migration SQL

Create packages/authz/src/migrations.ts:

import type { Dialect } from "@wrnexus/db";

/**
 * DDL for the two assignment tables. `scope` holds a tenant id, or the empty
 * string for a global assignment, so the unique constraints work on every
 * dialect (NULL is not comparable in a UNIQUE index).
 */
/**
 * DDL for the two assignment tables, as a list of statements rather than one
 * blob: splitting a blob on a separator makes runtime correctness depend on
 * source formatting, and only the sqlite driver accepts multi-statement exec.
 *
 * `scope` holds a tenant id, or the empty string for a global assignment, so
 * the unique constraints work on every dialect (NULL is not comparable in a
 * UNIQUE index). `effect` is CHECK-constrained: an unrecognised value would
 * otherwise be dropped from both the grant and deny buckets on read, silently
 * turning a deny into a no-op.
 */
export function authzMigrationSql(dialect: Dialect): { up: string[]; down: string[] } {
  const id =
    dialect === "postgres"
      ? "SERIAL PRIMARY KEY"
      : dialect === "mysql"
        ? "INT AUTO_INCREMENT PRIMARY KEY"
        : "INTEGER PRIMARY KEY AUTOINCREMENT";
  const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
  // MySQL's default collation is case- and accent-insensitive, which would let
  // tenant "T1" match "t1" and collapse roles "admin"/"Admin" onto one row.
  const exact = dialect === "mysql" ? " COLLATE utf8mb4_bin" : "";
  const key = `VARCHAR(255)${exact} NOT NULL`;

  return {
    up: [
      `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment (
  id ${id},
  subject_id ${key},
  scope ${key} DEFAULT '',
  role ${key},
  created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role)
)`,
      `CREATE TABLE IF NOT EXISTS _wrn_authz_grant (
  id ${id},
  subject_id ${key},
  scope ${key} DEFAULT '',
  permission ${key},
  effect VARCHAR(16) NOT NULL CHECK (effect IN ('allow', 'deny')),
  created_at ${timestamp} NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission)
)`,
    ],
    down: ["DROP TABLE IF EXISTS _wrn_authz_grant", "DROP TABLE IF EXISTS _wrn_authz_assignment"],
  };
}
  • Step 4: Write the adapter

Create packages/authz/src/db.ts:

import type { Db, Dialect } from "@wrnexus/db";
import { authzMigrationSql } from "./migrations.ts";
import { scopeKey, type GrantEffect, type PermissionStore } from "./store.ts";
import type { AuthzScope, SubjectAssignments } from "./types.ts";

// Re-exported so `@wrnexus/authz/db` is the single entry point for everything
// database-related, including the DDL the CLI scaffolds.
export { authzMigrationSql } from "./migrations.ts";

/** Create the tables if absent. Production apps should use a real migration. */
/** Create the tables if absent. Production apps should use a real migration. */
export async function ensureAuthzTables(
  db: Db,
  dialect: Dialect = db.driver.dialect,
): Promise<void> {
  for (const statement of authzMigrationSql(dialect).up) await db.exec(statement);
}

/** Positional placeholder for the dialect: postgres numbers them, others use "?". */
function ph(dialect: Dialect, index: number): string {
  return dialect === "postgres" ? `$${index}` : "?";
}

export function dbPermissionStore(db: Db): PermissionStore {
  const dialect = db.driver.dialect;
  const p = (n: number) => ph(dialect, n);
  // Single-statement upserts. A transaction here would be worse than useless:
  // the drivers run BEGIN on one shared connection, so an open transaction
  // swallows any concurrent write from another method and discards it on
  // rollback - a revoke would resolve successfully while the role survived.
  const onConflict = (columns: string, update: string) =>
    dialect === "mysql"
      ? ` ON DUPLICATE KEY UPDATE ${update}`
      : ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`;
  const onConflictIgnore = (columns: string) =>
    dialect === "mysql"
      ? " ON DUPLICATE KEY UPDATE id = id"
      : ` ON CONFLICT (${columns}) DO NOTHING`;

  return {
    async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments> {
      const key = scopeKey(scope);
      // A request inside a tenant sees global rows plus that tenant's rows.
      const roleRows = await db.all<{ role: string }>(
        `SELECT role FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
        [subjectId, key],
      );
      const grantRows = await db.all<{ permission: string; effect: string }>(
        `SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
        [subjectId, key],
      );
      return {
        roles: roleRows.map((row) => row.role),
        grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
        // Anything that is not literally "allow" counts as a deny, so a
        // corrupted or mis-cased effect fails closed rather than vanishing.
        denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission),
      };
    },

    async assignRole(subjectId, role, scope) {
      await db.exec(
        `INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` +
          onConflictIgnore("subject_id, scope, role"),
        [subjectId, scopeKey(scope), role],
      );
    },

    async revokeRole(subjectId, role, scope) {
      await db.exec(
        `DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`,
        [subjectId, scopeKey(scope), role],
      );
    },

    async grant(subjectId, permission, effect, scope) {
      await db.exec(
        `INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` +
          onConflict(
            "subject_id, scope, permission",
            "effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"),
          ),
        [subjectId, scopeKey(scope), permission, effect],
      );
    },

    async revokeGrant(subjectId, permission, scope) {
      await db.exec(
        `DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`,
        [subjectId, scopeKey(scope), permission],
      );
    },

    async listSubjects(scope) {
      const key = scopeKey(scope);
      const rows = await db.all<{ subject_id: string }>(
        `SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ${p(1)} ` +
          `UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`,
        [key, key],
      );
      return [...new Set(rows.map((row) => row.subject_id))];
    },
  };
}
  • Step 5: Add the subpath export

In packages/authz/package.json, replace the exports block with:

  "exports": {
    ".": "./src/index.ts",
    "./db": "./src/db.ts"
  },

Add "@wrnexus/authz/db": ["./packages/authz/src/db.ts"] to paths in the root tsconfig.json, next to the existing @wrnexus/authz entry.

  • Step 6: Run test to verify it passes

Run: bun test packages/authz/test/store-db.test.ts Expected: PASS — the same 13 conformance tests as the memory adapter

  • Step 7: Commit
git add packages/authz/src/db.ts packages/authz/src/migrations.ts packages/authz/package.json packages/authz/test/store-db.test.ts tsconfig.json
git commit -m "feat(authz): add database-backed PermissionStore"

Task 12: Permission type codegen

Files:

  • Create: packages/authz/src/codegen.ts
  • Test: packages/authz/test/codegen.test.ts

Interfaces:

  • Consumes: AuthzCatalog from ./types.ts

  • Produces: generatePermissionTypes(catalog: AuthzCatalog): string

  • Step 1: Write the failing test

Create packages/authz/test/codegen.test.ts:

import { describe, expect, test } from "bun:test";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs, emptyCatalog } from "../src/catalog.ts";
import { generatePermissionTypes } from "../src/codegen.ts";

describe("generatePermissionTypes", () => {
  test("emits sorted Permission and Role unions", () => {
    const catalog = mergeCatalogs([
      {
        source: "t.ts",
        module: defineAuthz({
          permissions: { "post:write": {}, "post:read": {} },
          roles: { editor: ["post:*"], admin: ["*"] },
        }),
      },
    ]);
    const out = generatePermissionTypes(catalog);
    expect(out).toContain('export type Permission = "post:read" | "post:write";');
    expect(out).toContain('export type Role = "admin" | "editor";');
    expect(out).toContain("DO NOT EDIT");
  });

  test("emits never for an empty catalog so the file still typechecks", () => {
    const out = generatePermissionTypes(emptyCatalog());
    expect(out).toContain("export type Permission = never;");
    expect(out).toContain("export type Role = never;");
  });

  test("escapes quotes in identifiers", () => {
    const catalog = mergeCatalogs([{ source: "t.ts", module: { roles: { 'we"ird': [] } } }]);
    expect(generatePermissionTypes(catalog)).toContain('"we\\"ird"');
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/authz/test/codegen.test.ts Expected: FAIL — cannot resolve ../src/codegen.ts

  • Step 3: Write the implementation

Create packages/authz/src/codegen.ts:

import type { AuthzCatalog } from "./types.ts";

function union(values: string[]): string {
  if (!values.length) return "never";
  // JSON.stringify, not hand-rolled escaping: role names reach this via the
  // raw mergeCatalogs path without the registry's id validation, so a value
  // may contain a newline, which manual quote/backslash escaping would emit
  // as an unterminated string literal.
  return values
    .slice()
    .sort()
    .map((value) => JSON.stringify(value))
    .join(" | ");
}

/**
 * Emit compile-time unions for the registered permissions and roles, so a
 * typo in can(ctx, "post:wrtie") is a type error rather than a silent false.
 */
export function generatePermissionTypes(catalog: AuthzCatalog): string {
  return `// Generated by \`wrnexus authz generate\`. DO NOT EDIT.

export type Permission = ${union([...catalog.permissions.keys()])};

export type Role = ${union([...catalog.roles.keys()])};
`;
}
  • Step 4: Export it

Append to packages/authz/src/index.ts (Task 13 imports this from the package entry):

export { generatePermissionTypes } from "./codegen.ts";
  • Step 5: Run test to verify it passes

Run: bun test packages/authz/test/codegen.test.ts && bun run generate:public-api Expected: PASS, 3 tests; baseline updated

  • Step 6: Commit
git add packages/authz/src/codegen.ts packages/authz/src/index.ts packages/authz/test/codegen.test.ts docs/public-api-0.8.json
git commit -m "feat(authz): generate Permission and Role union types"

Task 13: wrnexus authz CLI

Files:

  • Create: packages/cli/src/authz.ts
  • Modify: packages/cli/src/index.ts (add case "authz" next to case "db")
  • Test: packages/cli/test/authz-command.test.ts

Interfaces:

  • Consumes: buildRouter from @wrnexus/router; mergeCatalogs, generatePermissionTypes, authzMigrationSql from @wrnexus/authz

  • Produces: loadAuthzCatalog(appDir: string): Promise<AuthzCatalog>, runAuthzCommand(root: string, sub: string | undefined, args: string[]): Promise<void>

  • Step 1: Write the failing test

Create packages/cli/test/authz-command.test.ts:

import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadAuthzCatalog, runAuthzCommand } from "../src/authz.ts";

function scaffold(): string {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-cli-"));
  mkdirSync(join(root, "app", "authz"), { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  writeFileSync(
    join(root, "app", "authz", "blog.ts"),
    `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({
  permissions: { "post:read": { title: "View posts" }, "post:write": {} },
  roles: { editor: ["post:*"] },
});
`,
    "utf8",
  );
  return root;
}

describe("wrnexus authz", () => {
  test("loadAuthzCatalog merges every declaration", async () => {
    const catalog = await loadAuthzCatalog(join(scaffold(), "app"));
    expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "post:write"]);
    expect([...catalog.roles.keys()]).toEqual(["editor"]);
  });

  test("generate writes the permission types file", async () => {
    const root = scaffold();
    await runAuthzCommand(root, "generate", []);
    const generated = readFileSync(join(root, "app", "authz", "permissions.gen.ts"), "utf8");
    expect(generated).toContain('export type Permission = "post:read" | "post:write";');
  });

  test("init writes a migration containing both tables", async () => {
    const root = scaffold();
    mkdirSync(join(root, "app", "db", "migrations"), { recursive: true });
    await runAuthzCommand(root, "init", []);
    const dir = join(root, "app", "db", "migrations");
    const file = require("node:fs")
      .readdirSync(dir)
      .find((name: string) => name.includes("authz"));
    expect(file).toBeDefined();
    const sql = readFileSync(join(dir, file!), "utf8");
    expect(sql).toContain("_wrn_authz_assignment");
    expect(sql).toContain("_wrn_authz_grant");
    expect(sql).toContain("-- +down");
  });

  test("list prints every permission and role", async () => {
    const root = scaffold();
    const lines: string[] = [];
    const original = console.log;
    console.log = (...args: unknown[]) => void lines.push(args.join(" "));
    try {
      await runAuthzCommand(root, "list", []);
    } finally {
      console.log = original;
    }
    const output = lines.join("\n");
    expect(output).toContain("post:read");
    expect(output).toContain("editor");
  });

  test("an unknown subcommand throws with usage", async () => {
    await expect(runAuthzCommand(scaffold(), "bogus", [])).rejects.toThrow(/usage/i);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/cli/test/authz-command.test.ts Expected: FAIL — cannot resolve ../src/authz.ts

  • Step 3: Write the implementation

Create packages/cli/src/authz.ts:

/**
 * `wrnexus authz <cmd>` — authorization catalog tooling.
 *
 *   wrnexus authz list       print every registered permission, role, and policy
 *   wrnexus authz generate   write app/authz/permissions.gen.ts type unions
 *   wrnexus authz init       scaffold the assignment-table migration
 */

import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
  generatePermissionTypes,
  mergeCatalogs,
  type AuthzCatalog,
  type AuthzModule,
  type CatalogSource,
} from "@wrnexus/authz";
import { authzMigrationSql } from "@wrnexus/authz/db";

const USAGE = "usage: wrnexus authz <list|generate|init>";

/** Import every app/authz declaration and merge it into one catalog. */
export async function loadAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
  const router = buildRouter(appDir);
  const sources: CatalogSource[] = [];
  for (const entry of router.authz) {
    const imported = (await import(pathToFileURL(entry.file).href)) as {
      default?: AuthzModule;
    };
    if (!imported.default) {
      console.warn(`[wrnexus] ${entry.file} has no default export; skipping`);
      continue;
    }
    sources.push({ source: entry.file, module: imported.default });
  }
  return mergeCatalogs(sources);
}

function nextMigrationNumber(dir: string): string {
  if (!existsSync(dir)) return "0001";
  const numbers = readdirSync(dir)
    .map((name) => Number.parseInt(name.slice(0, 4), 10))
    .filter((value) => Number.isInteger(value));
  return String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, "0");
}

export async function runAuthzCommand(
  root: string,
  sub: string | undefined,
  args: string[],
): Promise<void> {
  const appDir = join(resolve(root), "app");

  switch (sub) {
    case "list": {
      const catalog = await loadAuthzCatalog(appDir);
      console.log(`Permissions (${catalog.permissions.size}):`);
      for (const [id, meta] of [...catalog.permissions].sort()) {
        const tags = [meta.risk && `risk=${meta.risk}`, meta.public && "public"]
          .filter(Boolean)
          .join(" ");
        console.log(`  ${id}${meta.title ? ` — ${meta.title}` : ""}${tags ? `  [${tags}]` : ""}`);
      }
      console.log(`\nRoles (${catalog.roles.size}):`);
      for (const [name, grants] of [...catalog.roles].sort()) {
        console.log(`  ${name}${grants.join(", ") || "(nothing)"}`);
      }
      console.log(`\nPolicies (${catalog.policies.size}):`);
      for (const name of [...catalog.policies.keys()].sort()) {
        const bound = [...catalog.bindings]
          .filter(([, names]) => names.includes(name))
          .map(([permission]) => permission);
        console.log(`  ${name}${bound.length ? ` → ${bound.join(", ")}` : "  (unbound)"}`);
      }
      return;
    }

    case "generate": {
      const catalog = await loadAuthzCatalog(appDir);
      const target = join(appDir, "authz", "permissions.gen.ts");
      mkdirSync(join(appDir, "authz"), { recursive: true });
      writeFileSync(target, generatePermissionTypes(catalog), "utf8");
      console.log(
        `Wrote ${target} (${catalog.permissions.size} permissions, ${catalog.roles.size} roles)`,
      );
      return;
    }

    case "init": {
      const dialect = (args.find((arg) => arg.startsWith("--dialect="))?.split("=")[1] ??
        "sqlite") as "sqlite" | "postgres" | "mysql";
      const dir = join(appDir, "db", "migrations");
      mkdirSync(dir, { recursive: true });
      const { up, down } = authzMigrationSql(dialect);
      const file = join(dir, `${nextMigrationNumber(dir)}_authz_tables.sql`);
      // up/down are statement LISTS; interpolating the arrays directly would
      // comma-join them into one unparseable statement.
      const block = (statements: string[]) => statements.map((s) => `${s};`).join("\n\n");
      writeFileSync(file, `-- +up\n${block(up)}\n\n-- +down\n${block(down)}\n`, "utf8");
      console.log(`Wrote ${file}`);
      console.log("Run `wrnexus db migrate` to apply it.");
      return;
    }

    default:
      throw new Error(USAGE);
  }
}
  • Step 4: Wire it into the CLI

In packages/cli/src/index.ts, add immediately after the case "db" block:

    case "authz": {
      bootstrapProfile(".", "development", rest);
      const { runAuthzCommand } = await import("./authz.ts");
      const [sub, ...authzArgs] = rest.filter((a) => !a.startsWith("--profile="));
      await runAuthzCommand(".", sub, authzArgs);
      break;
    }

Also add authz to the help text listing available commands.

  • Step 5: Run test to verify it passes

Run: bun test packages/cli/test/authz-command.test.ts Expected: PASS, 5 tests

  • Step 6: Commit
git add packages/cli/src/authz.ts packages/cli/src/index.ts packages/cli/test/authz-command.test.ts
git commit -m "feat(cli): add wrnexus authz list/generate/init"

Task 14: Wire the catalog into dev and prod boot

Files:

  • Modify: packages/dev-server/src/index.ts (load catalog in startServer)
  • Modify: packages/cli/src/build.ts (bake catalog into the prod manifest)
  • Test: packages/dev-server/test/authz-boot.test.ts

Interfaces:

  • Consumes: loadAuthzCatalog pattern from Task 13; authzMiddleware from @wrnexus/authz

  • Produces: RuntimeDeps.authz?: AuthzCatalog available to the request pipeline

  • Step 1: Write the failing test

Create packages/dev-server/test/authz-boot.test.ts:

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 { loadAppAuthzCatalog } from "../src/authz-boot.ts";

function scaffold(body: string): string {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-boot-"));
  mkdirSync(join(root, "app", "authz"), { recursive: true });
  mkdirSync(join(root, "app", "pages"), { recursive: true });
  writeFileSync(join(root, "app", "authz", "main.ts"), body, "utf8");
  return join(root, "app");
}

describe("loadAppAuthzCatalog", () => {
  test("loads declarations from app/authz", async () => {
    const appDir = scaffold(
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": {} } });`,
    );
    const catalog = await loadAppAuthzCatalog(appDir);
    expect(catalog.permissions.has("post:read")).toBe(true);
  });

  test("an app with no declarations gets an empty catalog rather than an error", async () => {
    const root = mkdtempSync(join(tmpdir(), "wrnexus-authz-empty-"));
    mkdirSync(join(root, "app", "pages"), { recursive: true });
    const catalog = await loadAppAuthzCatalog(join(root, "app"));
    expect(catalog.permissions.size).toBe(0);
  });

  test("a conflicting declaration fails the boot loudly", async () => {
    const appDir = scaffold(
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "low" } } });`,
    );
    writeFileSync(
      join(appDir, "authz", "other.ts"),
      `import { defineAuthz } from "@wrnexus/authz";
export default defineAuthz({ permissions: { "post:read": { risk: "high" } } });`,
      "utf8",
    );
    await expect(loadAppAuthzCatalog(appDir)).rejects.toThrow(/WRN-AUTHZ-CONFLICT/);
  });
});
  • Step 2: Run test to verify it fails

Run: bun test packages/dev-server/test/authz-boot.test.ts Expected: FAIL — cannot resolve ../src/authz-boot.ts

  • Step 3: Write the loader

Create packages/dev-server/src/authz-boot.ts:

import { pathToFileURL } from "node:url";
import { buildRouter } from "@wrnexus/router";
import {
  emptyCatalog,
  mergeCatalogs,
  type AuthzCatalog,
  type AuthzModule,
  type CatalogSource,
} from "@wrnexus/authz";

/**
 * Load and merge every `app/authz/*.ts` declaration. Conflicts throw so a
 * misconfigured catalog fails the boot rather than silently changing who can
 * do what.
 */
export async function loadAppAuthzCatalog(appDir: string): Promise<AuthzCatalog> {
  const router = buildRouter(appDir);
  if (!router.authz.length) return emptyCatalog();
  const sources: CatalogSource[] = [];
  for (const entry of router.authz) {
    // buildRouter already skips *.gen.ts, so only real declarations arrive here.
    const imported = (await import(pathToFileURL(entry.file).href)) as { default?: AuthzModule };
    if (!imported.default) continue;
    sources.push({ source: entry.file, module: imported.default });
  }
  return mergeCatalogs(sources);
}
  • Step 4: Run test to verify it passes

Run: bun test packages/dev-server/test/authz-boot.test.ts Expected: PASS, 3 tests

  • Step 5: Commit
git add packages/dev-server/src/authz-boot.ts packages/dev-server/test/authz-boot.test.ts
git commit -m "feat(dev-server): load the authz catalog at boot"

Task 15: Example app wiring and documentation

Files:

  • Create: examples/auth-showcase/app/authz/showcase.ts
  • Modify: packages/authz/README.md
  • Test: packages/authz/test/integration.test.ts

Interfaces:

  • Consumes: the full surface from Tasks 1-14

  • Produces: a worked end-to-end example proving the pieces compose

  • Step 1: Write the failing integration test

Create packages/authz/test/integration.test.ts:

import { describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts";
import {
  authzMiddleware,
  can,
  cachedPermissionStore,
  defineAuthz,
  guardPermission,
  memoryAuditSink,
  mergeCatalogs,
} from "../src/index.ts";

const catalog = mergeCatalogs([
  {
    source: "showcase.ts",
    module: defineAuthz({
      permissions: {
        "post:read": { public: true },
        "post:write": {},
        "post:delete": { risk: "high" },
      },
      roles: { editor: ["post:write"], admin: ["role:editor", "post:delete"] },
      policies: {
        ownsPost: async (s: { id?: string }, r?: { authorId?: string }) =>
          r?.authorId === s?.id ? { allowed: true } : { allowed: false, reason: "not owner" },
      },
      bindings: { "post:delete": ["ownsPost"] },
    }),
  },
]);

function makeCtx(user: unknown, tenantId?: string): Context {
  return {
    user,
    tenant: tenantId ? { id: tenantId } : undefined,
    locals: {},
    url: new URL("http://localhost/"),
    req: new Request("http://localhost/"),
  } as unknown as Context;
}

describe("end-to-end authorization", () => {
  test("db store, cache, catalog, middleware, and audit compose", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 1_000 });
    const audit = memoryAuditSink();
    await store.assignRole("alice", "admin", { tenantId: "acme" });

    const alice = makeCtx({ id: "alice" }, "acme");
    await authzMiddleware({ catalog, store, audit, strict: true })(
      alice,
      async () => new Response("ok"),
    );

    expect(await can(alice, "post:write")).toBe(true);
    expect(await can(alice, "post:delete", { id: 1, authorId: "alice" })).toBe(true);
    expect(await can(alice, "post:delete", { id: 2, authorId: "bob" })).toBe(false);

    // Wrong tenant: the admin role was scoped to acme.
    const elsewhere = makeCtx({ id: "alice" }, "other");
    await authzMiddleware({ catalog, store, strict: true })(
      elsewhere,
      async () => new Response("ok"),
    );
    expect(await can(elsewhere, "post:write")).toBe(false);

    // Anonymous can still read, because post:read is public.
    const guest = makeCtx(null);
    await authzMiddleware({ catalog, store, strict: true })(guest, async () => new Response("ok"));
    expect(await can(guest, "post:read")).toBe(true);
    expect(await can(guest, "post:write")).toBe(false);

    // Only denials were audited.
    expect(audit.events.every((event) => !event.allowed)).toBe(true);
    expect(audit.events.length).toBeGreaterThan(0);
  });

  test("revoking a role takes effect immediately through the cache", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const store = cachedPermissionStore(dbPermissionStore(db), { ttlMs: 60_000 });
    await store.assignRole("bob", "editor");

    const before = makeCtx({ id: "bob" });
    await authzMiddleware({ catalog, store, strict: true })(before, async () => new Response("ok"));
    expect(await can(before, "post:write")).toBe(true);

    await store.revokeRole("bob", "editor");

    const after = makeCtx({ id: "bob" });
    await authzMiddleware({ catalog, store, strict: true })(after, async () => new Response("ok"));
    expect(await can(after, "post:write")).toBe(false);
  });

  test("guardPermission returns an opaque 403", async () => {
    const db = createDb(sqlite(":memory:"));
    await ensureAuthzTables(db, "sqlite");
    const ctx = makeCtx({ id: "carol" });
    await authzMiddleware({ catalog, store: dbPermissionStore(db), strict: true })(
      ctx,
      async () => new Response("ok"),
    );
    const res = await guardPermission("post:write")(ctx, async () => new Response("passed"));
    expect(res.status).toBe(403);
    expect(await res.json()).toEqual({ ok: false, error: "Forbidden" });
  });
});
  • Step 2: Run test to verify it fails or passes

Run: bun test packages/authz/test/integration.test.ts Expected: PASS if Tasks 1-14 are correct. Any failure here is a real integration defect — fix the underlying module, not the test.

  • Step 3: Add the example declaration

Create examples/auth-showcase/app/authz/showcase.ts:

import { defineAuthz } from "@wrnexus/authz";

export default defineAuthz({
  permissions: {
    "post:read": { title: "View posts", public: true },
    "post:write": { title: "Create and edit posts" },
    "post:delete": { title: "Delete posts", risk: "high" },
    "admin:access": { title: "Reach the admin area", risk: "high" },
  },
  roles: {
    viewer: ["post:read"],
    editor: ["role:viewer", "post:write"],
    admin: ["role:editor", "post:delete", "admin:access"],
  },
  policies: {
    ownsPost: async (
      subject: { id?: string },
      resource?: { authorId?: string },
    ): Promise<{ allowed: boolean; reason?: string }> =>
      resource?.authorId === subject?.id
        ? { allowed: true }
        : { allowed: false, reason: "You are not the author" },
  },
  bindings: { "post:delete": ["ownsPost"] },
});
  • Step 4: Document the surface

Append to packages/authz/README.md:

## Declaring permissions

Put declarations in `app/authz/<name>.ts`. They are discovered automatically.

```ts
import { defineAuthz, owner } from "@wrnexus/authz";

export default defineAuthz({
  permissions: {
    "post:read": { title: "View posts", public: true },
    "post:delete": { title: "Delete posts", risk: "high" },
  },
  roles: { editor: ["post:*"], admin: ["role:editor"] },
  policies: { ownsPost: owner("id", "authorId") },
  bindings: { "post:delete": ["ownsPost"] },
});
```

## Checking permissions

Register the middleware once, then use `can()` and `guardPermission()`:

```ts
import { authzMiddleware, can, guardPermission } from "@wrnexus/authz";
import { dbPermissionStore } from "@wrnexus/authz/db";
import { getDb } from "@wrnexus/db";

export default [authzMiddleware({ catalog, store: dbPermissionStore(getDb()) })];

// in a route
export const middleware = [guardPermission("post:write")];
if (await can(ctx, "post:delete", post)) {
  /* ... */
}
```

`can()` is a free function, not `ctx.can``@wrnexus/core` must not depend on
`@wrnexus/authz`.

## Precedence

1. An explicit deny wins over everything, including `*`.
2. A bound policy can veto a permission a role grants.
3. Otherwise the permission must be held via a role or an explicit grant.
4. Default deny.

Every failure — unknown permission, store outage, policy exception — denies.

## CLI

```bash
wrnexus authz list      # every registered permission, role, and policy
wrnexus authz generate  # app/authz/permissions.gen.ts type unions
wrnexus authz init      # scaffold the assignment-table migration
```
  • Step 5: Run the full gate

Run: bun run check:production Expected: PASS. If check:public-api complains, run bun run generate:public-api and re-run.

  • Step 6: Commit
git add packages/authz/test/integration.test.ts packages/authz/README.md examples/auth-showcase/app/authz/showcase.ts docs/public-api-0.8.json
git commit -m "test(authz): end-to-end integration coverage, example, and docs"

Deferred phases

These are not in scope for this plan. Each needs its own design pass.

Phase 4 — .wrn view integration

Exposing can() inside compiler-generated {#if} expressions touches packages/compiler/src/codegen.ts. Because {#if} compiles to a nested ternary inside a template literal and can() is async, the resolution must happen before the view renders — most likely by collecting referenced permissions at compile time and pre-resolving them into the SSR scope, the way collectControlExprs already pre-resolves ssr { api ... } bindings. Do not begin this without confirming that shape against the codegen.

Phase 5 — Admin UI

.wrn components for listing subjects and assigning roles, shipped in @wrnexus/ui behind the existing wrnexus eject mechanism. Depends on listSubjects and the CLI landing first.

Inter-app communication seam

exportSubjectContext(ctx) / importSubjectContext(token) are specified in the design doc but intentionally unbuilt. They belong to the inter-app communication system, which has not been designed yet.