Files
WRNexusJS/docs/plans/2026-08-04-authz-permissions-implementation.md
T
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +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: WrNexus 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: WrNexus 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 integration 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.