---
name: audit-dev-external-api
description: Audit the PUBLIC (third-party, machine-to-machine) API surface of a generated client extension — whitelisted route prefix, seeded catalogue row, catalogue-permission ↔ compiled-constant parity, mandatory tenantId binding, server paging caps, the platform export envelope, business-layer reuse, BA declaration parity, published-contract freshness, seed-provider DI registration, grant grain and the class-level guard block (DEV-XAPI-001..014)
group: D
phase: devApi
kind: audit
audit_only: true
section_label: 'API externe (M2M)'
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# audit-dev-external-api — Public API Conformity Audit

## Context

You are auditing the surface a THIRD-PARTY system consumes machine-to-machine
in a generated SmartStack client extension: the controllers emitted by
`scaffold-external-api` under `/api/v1/export/{catalogue-code}`, the catalogue
rows their seed provider writes, and the `**API externe**` lines of `entité.md`
that authorised them.

**Why a dedicated pack.** This stratum fails in two ways that every other audit
is blind to, and that a browser can never reveal:

1. **The route exists, nothing seeded its catalogue row.**
   `DataApiAccessMiddleware` resolves the 4th path segment against
   `core.auth_DataExportEndpoints`; with no row it answers **404
   `endpoint_not_found`** to every external app — while a signed-in human hits
   the same URL successfully. The endpoint looks shipped and is unreachable.
2. **The catalogue permission and the compiled constant disagree.**
   The platform derives an external app's whole claim set from the
   `RequiredPermission` of the endpoints it was granted
   (`PermissionService.ResolveFromApiAccessAsync`) — never from roles. A
   catalogue row granting `…​.read` while the action demands `…​.create` answers
   **403 `permission_mismatch`** on a perfectly formed call, and no amount of
   re-granting fixes it.

A third stake runs through the whole pack: **an external app carries no ambient
tenant.** `TenantResolutionMiddleware` reads `X-Tenant-Slug` or the `/t/{slug}/`
prefix, neither of which a machine caller sets, so `ICurrentTenantService.TenantId`
stays null and every generated tenant query filter would run unscoped. The
`?tenantId=` parameter is the platform's own convention here, and binding it is
not optional — DEV-XAPI-005 is the cross-tenant gate.

## Invocation

```bash
npx --prefer-offline tsx skills/development/audit-dev-external-api/cli/audit-dev-external-api/index.ts \
  --project-path "<repo-root>" \
  --app-code "crm" \
  --mode audit
```

`--backend-path` defaults to `<repo-root>/src`, `--ba-root` to
`<repo-root>/.smartstack/ba`. `--rules DEV-XAPI-003,DEV-XAPI-005` runs a subset.
Exit code 0 when no `err` finding — including the clean skip on a project that
publishes no public surface at all.

`--mode apply` performs **no rewrite**: every remedy is a `scaffold-external-api`
re-run, which is what `/audit-fix` executes.

## Rules

### DEV-XAPI-001 — The route stays on the whitelisted prefix
- **Severity**: err
- Check: the class `[Route("…")]` starts with `/api/v1/export/`, and the file
  declares **no** `[NavRoute]`. The platform's `NavigationRouteModelProvider`
  rewrites a `[NavRoute]` controller to `/api/{module}/{section}` — which
  `ExternalAppRouteGuardMiddleware` does not whitelist.
- **err**: label=`DEV_XAPI_001_err`, params=`{ controller, route, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api — the public
  stratum uses a literal [Route] under /api/v1/export, the only prefix an
  external-app principal may reach; anything else answers 403 route_blocked."

### DEV-XAPI-002 — A catalogue row for every route, and no dangling row
- **Severity**: err
- Check, both ways: every served code has a seeded `DataApiEndpoint` row whose
  `RouteTemplate` equals the served route; every seeded row has a controller.
- **err**: label=`DEV_XAPI_002_err`, params=`{ code, seeded, served, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api to regenerate
  the catalogue seed provider — without the row the route answers 404
  endpoint_not_found to every external app while a signed-in user sees it work."

### DEV-XAPI-003 — The catalogue permission satisfies every action constant
- **Severity**: err
- Check: for each action, resolve `[RequirePermission(X)]` against the emitted
  `{Mod}Permissions.{Section}` constants, then test the row's
  `RequiredPermission` against it with the platform's own matching semantics
  (exact, `*`, prefix `x.*` — mirrored in `lib/external-api-catalog.permissionMatches`).
- **err**: label=`DEV_XAPI_003_err`, params=`{ code, required, action, verb, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api so the catalogue
  row and the compiled constant come from the same derivation — the platform
  builds the app's claims from the row alone, so a mismatch is a 403 nobody can
  grant away."

### DEV-XAPI-004 — Code grammar, reserved codes, uniqueness
- **Severity**: err
- Check: `{applicationCode}-{sectionCode}[-{operation}]`, ≤ 100 characters, not
  one of the platform's own codes (`users`, `tenants`, `roles`, `tickets`,
  `navigation`, `workflows`), and served by exactly one controller.
  `DataApiEndpoint.Code` carries a UNIQUE index: a collision fails the BOOT.
- **err**: label=`DEV_XAPI_004_err`, params=`{ code, controllers, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Rename through scaffold-external-api so the
  code, the route and the contract move together."

### DEV-XAPI-005 — tenantId taken, refused when empty, actually bound
- **Severity**: err
- Check: the controller carries the `BindTenantAsync` guard with its
  `Guid.Empty` refusal, an `IsExternalApp` branch calling `SetByIdAsync`, AND
  the fail-closed branch refusing an arbitrary `tenantId` for a signed-in human
  (the app-binding and grant-whitelist gates only run for an external-app
  principal). Every action takes `Guid tenantId` and calls the binder.
- **err**: label=`DEV_XAPI_005_err`, params=`{ controller, verb, line, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api — it emits
  BindTenantAsync and calls it before the service. A machine caller has no
  ambient tenant, so an unbound action reads across tenants."

### DEV-XAPI-006 — Server-side paging and declared limits
- **Severity**: err
- Check: a collection endpoint clamps `pageSize`; the catalogue row declares a
  rate limit and a page cap (the platform's per-app limiter reads them there).
- **err**: label=`DEV_XAPI_006_err`, params=`{ controller, code, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api. The audit
  middleware buffers the WHOLE response in memory to capture error bodies, so an
  unbounded page is paid in server RAM."

### DEV-XAPI-007 — The platform export envelope, ProblemDetails errors
- **Severity**: err (envelope), warn (error shape)
- Check: a collection endpoint returns `PaginatedExportResult<T>` — the shape
  the platform's own `/api/v1/export` endpoints use — and declares
  `ProblemDetails` responses.
- **err**: label=`DEV_XAPI_007_err`, params=`{ controller, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api so a third party
  consuming both the platform endpoints and yours meets ONE envelope."

### DEV-XAPI-008 — The same business layer as the other strata
- **Severity**: err
- Check: the controller injects an `I{Entity}Service` and never names a
  `DbContext`. This is DEV-API-014 extended to the public stratum.
- **err**: label=`DEV_XAPI_008_err`, params=`{ controller, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api — querying
  around the business layer means validation, data scopes and business rules do
  not apply to third-party traffic."

### DEV-XAPI-009 — Write surface declared as such, and retry-safe
- **Severity**: err (AccessType), warn (missing unique index)
- Check: a controller serving POST/PUT/DELETE has `AccessType: Write` on its
  row; an entity with a create endpoint declares a unique index.
- **err**: label=`DEV_XAPI_009_err`, params=`{ code, accessType, file }`
- **warn**: label=`DEV_XAPI_009_warn`, params=`{ code, entity, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true (err), false (warn)
- **solution** (mandatory on err): "Re-run scaffold-external-api. For the warn:
  declare naturalKey[] AND a unique index — the platform maps SQL 2601/2627 to a
  409, so the index IS the retry guard. An application-level probe is not: it
  races, and the platform's Idempotency-Key is inert for a machine caller (it
  scopes on TenantId+UserId, both null for an external app)."

### DEV-XAPI-010 — BA declaration parity, both ways
- **Severity**: err
- Check: every served endpoint is authorised by an `**API externe**` line in the
  application's `entité.md`; every such line is served; no unreadable token on
  the line. A public endpoint has no screen, so no pagespec can declare it —
  `entité.md` is the authoring channel, beside the existing `**API** : none`.
- **err**: label=`DEV_XAPI_010_err`, params=`{ code, controller, operations, unknown, file }`
- **fixSkill**: `ba-create-data-model` (authoring) / `external-api` (generation), **fixPhaseKey**: `api`, autoFixable: false
- **solution** (mandatory on err): "Author the `- **API externe** : read, create`
  line in entité.md, then run /external-api. An endpoint nobody authorised is an
  undocumented door into the tenant's data."

### DEV-XAPI-011 — The published contract still matches the code
- **Severity**: err (drift), warn (no contract yet)
- Check: `.smartstack/api-public/**/openapi.json` documents exactly the served
  codes. A contract advertising a retired endpoint is a breaking change already
  in a third party's hands.
- **err**: label=`DEV_XAPI_011_err`, params=`{ code }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: false
- **solution** (mandatory on err): "Run publish-api-contract. If an endpoint was
  removed, that is a BREAKING change: restore it or publish a new version."

### DEV-XAPI-012 — The catalogue seed provider is registered
- **Severity**: err
- Check: a `<<< PUBLIC-API-SEED-DI-{App} >>>` block exists in the
  Infrastructure `DependencyInjection.cs` whenever a provider was emitted.
- **err**: label=`DEV_XAPI_012_err`, params=`{ rows }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api, which splices
  the DI block. Unregistered, the provider never runs and every public endpoint
  answers 404 while looking perfectly healthy in the browser."

### DEV-XAPI-013 — The wildcard the resource grain forces
- **Severity**: warn
- Check: no catalogue row grants a prefix wildcard (`{node}.*`) or `*`.
- **warn**: label=`DEV_XAPI_013_warn`, params=`{ code, permission, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: false
- **solution**: "Prefer granularity 'operation'. A wildcard covers every action
  of the section — including one added later, granted retroactively to every
  application already holding the endpoint — and makes a read-only partner
  impossible, since the grant grain IS the catalogue code."

### DEV-XAPI-014 — The class-level guard block
- **Severity**: err
- Check: `[Authorize]`, `[EnableRateLimiting]` and `[RequiresLicenseFeature]` at
  class level, and every action carrying `[RequirePermission]` or an explicit
  `[AllowAnonymous]`.
- **err**: label=`DEV_XAPI_014_err`, params=`{ controller, missing, verb, line, file }`
- **fixSkill**: `external-api`, **fixPhaseKey**: `api`, autoFixable: true
- **solution** (mandatory on err): "Re-run scaffold-external-api. This is the
  platform's own WorkflowsApiController defect reproduced in a client extension:
  without [Authorize] the authentication is a side effect of the permission
  filter, and without the rate-limit policy one third party can saturate the
  surface for all the others."

## Auto-healing

| Rule | Remedy | Auto |
|---|---|---|
| 001, 002, 003, 004, 005, 006, 007, 008, 009 (err), 012, 014 | re-run `scaffold-external-api` | yes |
| 009 (warn) | declare `naturalKey[]` + a unique index on the entity | no |
| 010 | author the `**API externe**` line (`/ba-create-data-model`), then `/external-api` | no |
| 011 | run `publish-api-contract`; a removal needs a contract version bump | no |
| 013 | change the grant grain (a product decision, not a defect) | no |
