---
name: odc-api
description: Change apps/api — an endpoint or controller, a route's `@RouteAccess`, a service's `accessibleQuery` scoping, a Prisma model or a CASL permission — or an environment variable anywhere in the repo. Use also when asked who can see or do what.
---

`apps/api` serves clinical data segregated by group. Access is decided in two independent places: a
**guard** that sees the request and no data, and a **scope** in the Prisma `where` that sees the
rows. The only half a tool checks is the guard's presence (`REQUIRE_ROUTE_ACCESS` in the root
`eslint.config.js`); the guard's value, and the scope entirely, are **silent** — no compiler, no
test — so satisfying one and forgetting the other is green everywhere.

## The scope is the half nothing checks

**`accessibleQuery(undefined, …)` returns `{}`.** `apps/api/src/auth/ability.utils.ts` opens the
function with `if (!ability) { return {}; }` — an empty `where`, which reads every group's rows. The
ability is optional by type (`EntityOperationOptions` in `apps/api/src/core/types.ts` is
`{ ability?: AppAbility }`), so `tsc` is satisfied, and a service spec running against a mocked
model asserts back whatever arguments you passed it. This is the highest-severity mistake available
in this repo, and the only thing standing in front of it is you reading the `where`
(`.agents/docs/architecture/auth-and-permissions.md`, Layer 2).

**Forwarding is the other half, and it has two shapes.** Either the service method takes
`{ ability }: EntityOperationOptions` and its controller forwards `@CurrentUser('ability')`, or it
takes `currentUser?: RequestUser` and reads `.ability` off it itself while its controller forwards
`@CurrentUser()` — the second shape is `apps/api/src/instruments/instruments.service.ts` and
`apps/api/src/instrument-records/files/files.service.ts`. In `instruments.service.ts` only
`findBundleById` takes it as optional, because the gateway resolves an assignment's bundle unscoped,
so a call site there that omits it compiles and queries every group.

**Unscoped is a decision, not an omission.** `AuditService.find` takes no ability at all, because a
manage-all guard is the whole check on `GET /v1/audit/logs`; the inventory of routes that are
deliberately unscoped is in `.agents/docs/architecture/auth-and-permissions.md`.

- **Done when every Prisma query in your diff is accounted for** — each one either names
  `accessibleQuery` in its `where`, or is unscoped for a reason your reply states. Every query, not
  a sample of them.
- **Done when every handler whose service method receives an ability forwards it** —
  `@CurrentUser('ability')` for the `EntityOperationOptions` shape, `@CurrentUser()` for the
  `RequestUser` shape — established by opening each handler, because nothing else establishes it.

## The guard: `@RouteAccess`

Every controller handler carries one; `REQUIRE_ROUTE_ACCESS` in the root `eslint.config.js` flags a
missing decorator, which is a 500 at request time rather than an open route. That rule sees nothing
else — a wrong value is silent, and two of them are traps:

| Value      | Grants                                                                                             |
| ---------- | -------------------------------------------------------------------------------------------------- |
| `'public'` | No authentication at all. Adding one is a security decision — raise it rather than deciding alone. |
| `[]`       | Any authenticated user, because `[].every(...)` is `true`. Easy to write by accident.              |

`{ action, subject }` runs `ability.can` against the subject **type**, never against rows; an array
of them is `.every(...)`.

**Done when** every handler your diff adds or changes names a `@RouteAccess` value and your reply
says which — `'public'` and `[]` in writing, because neither is distinguishable from a considered
choice once written.

## The two lists that must agree

What a _user_ can be granted through `additionalPermissions` is not the CASL subject list Prisma
derives: it is `enum AppSubject` (`apps/api/prisma/schema.prisma`) and `$AppSubjectName`
(`packages/schemas/src/core/core.ts`), hand-written and deliberately narrower. A grant may also be
confined to one group, and the subjects that allows are `$GroupScopableSubjectName` (the same list
minus `all` and `Instrument`); `GROUP_SCOPED_CONDITIONS` in `apps/api/src/auth/ability.factory.ts`
names each one's group field and is typed over that list, so `tsc` reports a missing entry.

**Done when** a model users must hold a permission on appears in both lists in the same commit —
and in `GROUP_SCOPED_CONDITIONS` unless `$GroupScopableSubjectName` excludes it — or your reply
says you left the pair narrower deliberately.

## Where the procedure lives

The order of operations is silent when skipped, and lives in files this skill does not restate:

| When                                                              | Open                                                                                                                                                                                       |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Adding or reshaping an endpoint                                   | `.agents/docs/playbooks/add-api-endpoint.md` — read it before writing the first file; it carries the registration steps                                                                    |
| Any environment variable, in any workspace                        | `.agents/docs/playbooks/add-env-var.md` — `$Env` (`apps/api`) and `apps/gateway/src/config.ts` are separate declaration sites; declaring it in one and stopping there is the usual failure |
| Judging who may see or do what, or picking a `@RouteAccess` value | `.agents/docs/architecture/auth-and-permissions.md` — the route-by-route inventory, where an ability comes from, and which models are grantable to nobody                                  |
| Writing anything in this app                                      | `apps/api/AGENTS.md` — libnest replaces enough of NestJS that stock Nest tutorials mislead; read `apps/api/node_modules/@douglasneuroinformatics/libnest/src` for a signature              |
| Your change moved who can see what                                | `.agents/skills/odc-testing/SKILL.md` — row scoping is not observable in the tier `apps/api` tests itself in                                                                               |

## Consumers the compiler does not connect

Each hard-codes the path, the method and the expected status, so a rename or a changed status code
reaches it even when `tsc` sees the schema change:

| Consumer                            | Breaks as                                                                                                   |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `apps/web/src/hooks/`               | a 404 in the clinician SPA at runtime — `.agents/docs/playbooks/add-web-data-hook.md`                       |
| `cli/odc-cli`                       | nothing at all; it is outside the pnpm workspace and every check — `cli/AGENTS.md` holds its endpoint table |
| `testing/src/support/api-client.ts` | a red `pnpm test:e2e`, which CI does run — a failed run rather than a compile error                         |

**Done when** every hit of `grep -rn '<segment>' apps/web/src cli/odc-cli testing/src` is accounted
for — grep the bare segment, because `api-client.ts` builds every path as `${API}/<segment>` — and an
endpoint an e2e test would otherwise reach through the UI has a method on that file, or your reply
names the one that already seeds it.
