refactor(projects): move authz persistence into a repositories layer

Reorganises the authz lib along DDD lines by separating the persistence
boundary from the domain:

  src/lib/projects/authz.ts                        domain — pure decisions
  src/app/repositories/projects/authzRepository.ts persistence — Drizzle

The repository sits alongside src/app/db/ and mirrors the existing
db/schema/projects/ layout, so schema and repository stay symmetrical as
Ara Projects grows more of both.

Dependencies point one way: the repository imports the domain's fact types
and returns them, so the domain never learns a table shape and continues to
import no database client. The 34 guard tests still run with no connection.

No behaviour change — the queries and guards are untouched. Note this is a
new pattern for the codebase: the prevailing convention is a colocated
queries.ts/server.ts per lib folder, which 88 files still follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-21 16:12:26 +00:00
parent f358b874df
commit 1ce8a65faf
2 changed files with 20 additions and 13 deletions

View file

@ -1,8 +1,17 @@
/**
* DB-backed fact loaders feeding the pure guards in `./authz` (issue #408).
* Ara Projects authorization repository (issue #408).
*
* Kept separate from the guards so those stay unit-testable without a database.
* Route handlers load facts here, then decide with `authz`.
* The persistence boundary for authorization: it owns the Drizzle queries and
* returns domain-shaped facts (`ProjectAuthzFacts`, `WorkOrderAuthzFacts`)
* rather than raw rows, so the domain layer never sees a table shape. The
* decisions themselves live in `@/lib/projects/authz`, which imports no
* database client and unit-tests without a connection.
*
* const [user, project] = await Promise.all([
* loadAuthzUser(userId),
* loadProjectAuthzFacts(projectId),
* ]);
* if (!canViewProject(user, project)) return forbidden();
*
* Every loader is a single round trip; none probes per-row inside a list.
*/
@ -20,7 +29,7 @@ import type {
AuthzUser,
ProjectAuthzFacts,
WorkOrderAuthzFacts,
} from "./authz";
} from "@/lib/projects/authz";
/**
* The organisations a user belongs to, via `team_members → team → org_id`.

View file

@ -3,16 +3,14 @@
* Ara Projects route handler (issue #408). No route may re-implement any of
* this inline.
*
* This module is deliberately **pure**: it imports no database client, so the
* guards unit-test without mocking a connection pool. The DB-backed resolvers
* that produce its inputs live next door in `./authzQueries` a route handler
* loads facts there, then decides here.
* This is the domain layer: deliberately **pure**, importing no database
* client, so the guards unit-test without mocking a connection pool. It owns
* the vocabulary (`ProjectRole`, `ProjectAuthzFacts`) and the decisions.
*
* const [user, project] = await Promise.all([
* loadAuthzUser(userId),
* loadProjectAuthzFacts(projectId),
* ]);
* if (!canViewProject(user, project)) return forbidden();
* Persistence lives behind the repository at
* `@/app/repositories/projects/authzRepository`, which loads those facts. A
* route handler loads there, then decides here and the dependency only ever
* points that way, so the domain never learns a table shape.
*
* Scoping note: Ara Projects are **organisation**-scoped. The existing
* `team_portfolio_permissions` machinery is portfolio-scoped and plays no part