Files
apf_portal/package.json
T
julien 2eb01f59b3
CI / check (push) Successful in 3m4s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m53s
CI / a11y (push) Successful in 3m50s
CI / perf (push) Successful in 5m23s
Docs site / build (push) Successful in 5m26s
feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) (#211)
## Summary

Phase 3 (and last) of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information" / §347): a small CI gate that asserts every string literal passed to the authorization decorators belongs to the closed catalogue declared in `libs/shared/auth/src/lib/authorization.types.ts`.

The TypeScript decorator signatures (`(...privileges: [Privilege, ...Privilege[]])`) already enforce this at compile time. The gate is **defence-in-depth** against the escape hatches the type system cannot catch:

- explicit `as Privilege` / `as FunctionalRole` casts (`'Portal.Foo' as Privilege`),
- the rare case where a developer hand-edits the catalogue type union without updating the runtime constant.

Runs in `<1s`, so it rides the existing `check` CI job rather than spinning up its own.

## What lands

| File | Role |
| --- | --- |
| `scripts/check-catalogue-drift.mjs` | The gate. TypeScript compiler API. Parses `authorization.types.ts` to extract the catalogues, walks every `.ts` file under `apps/` and `libs/`, finds each `CallExpression` whose callee identifier matches `RequirePrivilege` / `RequireRole`, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file with `line:column` per violation, exits 1 on drift. |
| `scripts/check-catalogue-drift.spec.mjs` | 13 tests via `node --test` (built-in runner, no Vitest dep). Covers catalogue parsing, file-level violation detection, workspace-wide aggregation, skipped folders, gen-stub exclusion, line/column reporting. |
| `package.json` | `ci:catalogue-drift` + `ci:catalogue-drift:test` scripts. |
| `.gitea/workflows/ci.yml` | The existing `check` job now runs the gate's unit tests first (fail-fast if the gate itself is broken), then the gate against the live workspace. |

## Notes for the reviewer

- **Why not an ESLint custom rule.** ADR-0025 §347 floats either option. The ESLint route would give editor-time feedback (red squiggles) but means standing up a local ESLint plugin lib — net ~300 lines of plumbing for a gate the TypeScript signature already enforces in real time via the literal-union types. The pnpm-script route mirrors the existing `ci:gzip-budgets` pattern; ~250 lines including tests; runs in the same `check` CI job that already installs deps. Editor feedback is **already provided by `tsc`**, so the gate's job reduces to "catch escape hatches in CI", which the script does fine.

- **Why `node --test` rather than Vitest / Jest.** The script lives in `scripts/`, outside any Nx project. Wiring Vitest for one spec file would mean a vitest.config + tsconfig.spec + Nx project just to host it. Node's built-in test runner ships with the runtime we already pin (Node 24 in `.nvmrc`), no config, ~250ms wall clock for 13 tests.

- **Generated gRPC stubs are skipped.** `apps/portal-bff/src/grpc/gen/apf-ai/common.ts` defines a `roles: string[]` proto field used by the AI-bridge — entirely unrelated to the ADR-0025 functional-role catalogue. The skip list is explicit (`SKIPPED_SUBPATHS`); adding a new codegen output later is one line.

- **Spec files are NOT skipped.** `auth-guards.persona-matrix.spec.ts` references catalogue values via the decorators in its test fixtures. Those are deliberate references and must stay in sync — if a future ADR amendment removes a role, the spec catches it on the same run as the production code. Explicitly tested in `does NOT skip spec files`.

- **Non-literal arguments (variable indirection) are skipped.** A pattern like `const slug = getRole(); @RequireRole(slug)` would not be caught by string-literal inspection. This is intentional: the TypeScript decorator signature already requires `slug` to be typed `FunctionalRole`, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past an `as Privilege` cast.

- **Self-test confirmed the gate works.** Injected `RequirePrivilege('Portal.RogueDrift')` and `RequireRole('rogue-role-x')` into an existing spec, ran `pnpm ci:catalogue-drift`, observed:

  ```
  catalogue-drift: violations found

    apps/portal-bff/src/auth/require-privilege.guard.spec.ts
      135:18  @RequirePrivilege('Portal.RogueDrift') — not in PRIVILEGES
      136:13  @RequireRole('rogue-role-x') — not in FUNCTIONAL_ROLES
  ```

  Exit code 1. Reverted the injection; gate green again.

- **Adding a third decorator** (per ADR-0025's anticipated growth) is one line in `DECORATOR_TO_CATALOGUE` plus the matching test fixture. The script does not hardcode the decorator name list beyond that map.

- **Why no scope-kind check.** `@RequireScope` takes an `(req) => ScopableResource` extractor, not literals. The scope `kind` values are constrained by the `ScopeKind` discriminated union, which TypeScript enforces at every `{ kind: ... }` construction site. No literal escape hatch worth gating in v1.

## Test plan

- [x] `pnpm ci:catalogue-drift:test` — 13/13 green via `node --test`.
- [x] `pnpm ci:catalogue-drift` — clean against the live workspace: `catalogue-drift: clean (catalogues: 4 privileges, 24 roles).`
- [x] Self-test by injection — script catches `'Portal.RogueDrift'` and `'rogue-role-x'` with file:line:column, exits 1.
- [x] `pnpm exec prettier --check` on the new files — clean.
- [ ] CI run on this PR exercises the new step in the `check` job.

## What's next

ADR-0025's phasing closes with this PR. Remaining authorization work waits on [ADR-0026](docs/decisions/) (proposed) — the `Person` + `User` schema brings the Prisma-backed `user_scopes` table; that PR replaces `StubScopeResolver` with a `PrismaScopeResolver` and unlocks the first concrete `@RequireScope` consumer surfaces.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #211
2026-05-24 01:07:13 +02:00

206 lines
7.0 KiB
JSON

{
"name": "apf-portal",
"version": "0.0.0",
"license": "MIT",
"packageManager": "pnpm@10.33.4",
"scripts": {
"prepare": "husky",
"ci:check": "pnpm exec nx affected -t format:check lint test build",
"ci:audit": "pnpm audit --audit-level=moderate",
"ci:catalogue-drift": "node scripts/check-catalogue-drift.mjs",
"ci:catalogue-drift:test": "node --test scripts/check-catalogue-drift.spec.mjs",
"ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose",
"ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs",
"ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js",
"grpc:codegen": "grpc_tools_node_protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=apps/portal-bff/src/grpc/gen/apf-ai --ts_proto_opt=outputServices=grpc-js,esModuleInterop=true,forceLong=long,useOptionals=messages,exportCommonSymbols=false --proto_path=apps/portal-bff/src/grpc/proto/apf-ai apps/portal-bff/src/grpc/proto/apf-ai/*.proto",
"grpc:sync": "node scripts/sync-ai-protos.mjs",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"private": true,
"lint-staged": {
"!(pnpm-lock).{ts,tsx,js,jsx,html,scss,css,md,json,yml,yaml}": [
"prettier --write"
]
},
"pnpm": {
"onlyBuiltDependencies": [
"@nestjs/core",
"@parcel/watcher",
"@prisma/client",
"@prisma/engines",
"@swc/core",
"esbuild",
"grpc-tools",
"less",
"lmdb",
"msgpackr-extract",
"nx",
"prisma",
"unrs-resolver"
],
"overrides": {
"axios@<1.15.2": ">=1.15.2",
"@angular-devkit/core>ajv@<8.18.0": ">=8.18.0",
"brace-expansion@<5.0.6": ">=5.0.6",
"esbuild@<0.25.0": ">=0.25.0",
"follow-redirects@<1.16.0": ">=1.16.0",
"ip-address@<10.1.1": ">=10.1.1",
"protobufjs@<8.0.2": ">=8.0.2",
"tmp@<0.2.4": ">=0.2.4",
"uuid@<11.1.1": ">=11.1.1",
"vitepress>vite": ">=6.4.2 <7",
"ws@>=8.0.0 <8.20.1": ">=8.20.1",
"yaml@<2.8.3": ">=2.8.3",
"@types/express": "^5.0.6"
}
},
"prisma": {
"schema": "apps/portal-bff/prisma/schema.prisma"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "~2.5.0",
"@analogjs/vitest-angular": "~2.5.0",
"@angular-devkit/core": "~21.2.0",
"@angular-devkit/schematics": "~21.2.0",
"@angular/build": "~21.2.0",
"@angular/cli": "~21.2.0",
"@angular/compiler-cli": "~21.2.0",
"@angular/language-service": "~21.2.0",
"@braintree/sanitize-url": "^7.1.2",
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^20.5.3",
"@eslint/js": "^9.8.0",
"@lhci/cli": "^0.15.1",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nx/angular": "^22.7.1",
"@nx/devkit": "22.7.2",
"@nx/eslint": "^22.7.1",
"@nx/eslint-plugin": "22.7.2",
"@nx/jest": "22.7.2",
"@nx/js": "22.7.2",
"@nx/nest": "^22.7.1",
"@nx/node": "22.7.2",
"@nx/playwright": "22.7.2",
"@nx/vite": "^22.7.1",
"@nx/vitest": "22.7.2",
"@nx/web": "22.7.2",
"@nx/webpack": "22.7.2",
"@oxc-project/runtime": "^0.131.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "~21.2.0",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.40",
"@swc/helpers": "0.5.21",
"@tailwindcss/postcss": "^4.2.4",
"@types/cookie-parser": "^1.4.10",
"@types/d3": "^7.4.3",
"@types/d3-scale-chromatic": "^3.1.0",
"@types/d3-shape": "^3.1.8",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/jest": "^30.0.0",
"@types/node": "24.12.4",
"@typescript-eslint/utils": "^8.40.0",
"@vitest/coverage-v8": "~4.1.0",
"@vitest/ui": "~4.1.0",
"angular-eslint": "^21.2.0",
"cytoscape": "^3.33.3",
"cytoscape-cose-bilkent": "^4.1.0",
"dayjs": "^1.11.20",
"debug": "^4.4.3",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-playwright": "^1.6.2",
"husky": "^9.1.7",
"jest": "^30.0.2",
"jest-environment-node": "^30.0.2",
"jest-util": "^30.0.2",
"jsdom": "^29.0.0",
"jsonc-eslint-parser": "^2.1.0",
"lint-staged": "^17.0.0",
"mermaid": "^11.15.0",
"nx": "22.7.2",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.12",
"prettier": "^3.8.1",
"prisma": "^6.19.3",
"tailwindcss": "^4.2.4",
"ts-jest": "^29.4.0",
"ts-node": "10.9.2",
"ts-proto": "^2.7.0",
"tslib": "^2.3.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^8.0.0",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^4.0.8",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@angular/cdk": "~21.2.10",
"@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0",
"@angular/forms": "~21.2.0",
"@angular/localize": "~21.2.12",
"@angular/platform-browser": "~21.2.0",
"@angular/router": "~21.2.0",
"@azure/msal-node": "^5.2.1",
"@bufbuild/protobuf": "^2.10.2",
"@grpc/grpc-js": "^1.13.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.3",
"@observablehq/plot": "^0.6.17",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
"@opentelemetry/instrumentation": "^0.218.0",
"@opentelemetry/instrumentation-document-load": "^0.63.0",
"@opentelemetry/instrumentation-express": "^0.66.0",
"@opentelemetry/instrumentation-fetch": "^0.218.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/instrumentation-ioredis": "^0.66.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.64.0",
"@opentelemetry/instrumentation-pg": "^0.70.0",
"@opentelemetry/instrumentation-pino": "^0.64.0",
"@opentelemetry/instrumentation-user-interaction": "^0.62.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-web": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@prisma/client": "^6.19.3",
"@scalar/nestjs-api-reference": "^1.1.14",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"d3": "^7.9.0",
"d3-scale-chromatic": "^3.1.0",
"d3-shape": "^3.2.0",
"express-rate-limit": "^8.5.1",
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"ioredis": "^5.10.1",
"jose": "^6.2.3",
"long": "^5.2.3",
"lucide-angular": "^1.0.0",
"nestjs-cls": "^6.2.0",
"nestjs-pino": "^4.6.1",
"nestjs-prisma": "^0.27.0",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "~7.8.0"
},
"optionalDependencies": {
"grpc-tools": "^1.13.0"
}
}