Skip to content

Drift control

The Permissions Matrix is the only piece of this site that cannot drift from the live API. Every other page is hand-written, so the matrix is the canonical gate.

apps/docs/scripts/generate-permissions-matrix.ts runs at every build and at every dev start:

  1. It uses ts-morph to load every apps/api/src/**/*.controller.ts as a TypeScript AST.
  2. For each @Controller("<prefix>") class, it walks the methods.
  3. For each method that carries an HTTP decorator (@Get, @Post, @Patch, @Put, @Delete), it extracts:
    • The route path (controller prefix + method path, with the API’s global /v1 prefix prepended — health is exempt, matching apps/api/src/main.ts).
    • The literal arguments to @Roles(...).
    • Whether the method or its class is @Public().
    • The relative source file path and line number.
  4. It writes the result, sorted by modulepathmethod, to apps/docs/src/generated/permissions-matrix.json. That file is gitignored: it exists only at build time.
  5. It exits non-zero if any method has neither @Roles(...) nor @Public() and isn’t on a *Public*Controller class.

“Some controller methods declare no access posture.”

This is by design. Every new endpoint must opt into a posture, because “I forgot a decorator” must not become “anyone can call this endpoint.”

When the build fails with this error:

  1. Open the file/line printed in the error.
  2. Decide: should this endpoint require a role, or is it intentionally anonymous?
  3. Either:
    • Role-gated — add @Roles("member"), @Roles("teros-ops", "teros-ops-admin"), etc.
    • Public — add @Public() to the method (or, if the whole controller is public, the class). Public routes still need their own authorization mechanism — e.g., a tokenized URL.
  4. Re-run pnpm --filter docs build; the matrix regenerates with the new posture.
  • It does not verify that RolesGuard is actually registered globally. (It is, via AuthModule; that’s a one-line audit.)
  • It does not verify field-level access. Hand-maintained tables in Database & Prisma cover the most permission-sensitive models for now.
  • It does not check that OrganizationScopeGuard is applied where it should be. That stays a code review concern.

When a service changes its field filtering — e.g., a new field on CandidateProfile is added and only teros-ops* should be able to write it — update the relevant table in Database & Prisma. Drift between the table and the service stays hand-managed for now. A follow-up issue will explore automating this.

The matrix can only be wrong in three ways:

  1. A decorator argument the extractor doesn’t understand (e.g., someone passes a spread of a non-literal const). Fix the extractor in apps/docs/scripts/permissions-matrix-core.ts or refactor the decorator to use literal strings — the latter is the project’s preference.
  2. A new HTTP decorator name that isn’t in the extractor’s switch. Add it to HTTP_METHOD_DECORATORS.
  3. A new “public” pattern the extractor doesn’t recognize. Prefer @Public(); the naming-convention fallback exists only for the historical *Public*Controller classes.

The script lives next to the docs site, not inside apps/api. It is pure source analysis — no network, no database, no NestJS runtime — so it can run anywhere TypeScript can.