---
name: external-api
description: >
  Publish part of a generated SmartStack client app as a PUBLIC API that
  THIRD-PARTY systems consume machine-to-machine — the surface an ERP, a
  partner portal or a nightly batch calls. Emits controllers under the one
  route prefix the platform whitelists for an external application, seeds the
  endpoint catalogue that makes them reachable, publishes a versioned OpenAPI +
  Postman contract with a breaking-change gate, and provisions a test client so
  the whole chain can be proven end to end. The platform already owns identity,
  token exchange, grants, rate limiting and audit — this skill wires a client
  extension into it. Use for "exposer une API à un partenaire", "API externe",
  "machine-to-machine", "intégration tierce", "clé d'API", "expose our data to
  their ERP".
argument-hint: "[what to expose, e.g. 'les factures en lecture + création pour le partenaire logistique']"
allowed-tools: Read, Grep, Glob, Bash
---

# /external-api — publish a third-party API

## What the platform already gives you

Nothing here re-implements machine-to-machine access: `SmartStack.app` owns all
of it. Knowing exactly where the seam is, is the whole point of this skill.

| Brique | Owned by the platform |
|---|---|
| Third-party identity | `ExternalApplication` — clientId, primary + secondary secret (zero-downtime rotation), IP allow-list, `SecurityStamp` for instant revocation |
| Token exchange | `POST /api/auth/external-app/token`, a JWT HS256 client assertion (`sub` = clientId, `exp` ≤ 5 min) |
| Route guard | `ExternalAppRouteGuardMiddleware` — an external app may reach `/api/v1/export/*`, `/api/v1/workflows`, `/api/auth/external-app/*` and `/api/v1/me/access`. **Nothing else**, 403 `route_blocked` |
| Endpoint catalogue | `core.auth_DataExportEndpoints` — code, route, required permission, access type, rate limit, page cap |
| Grants | `ExternalApplicationApiAccess` — per application × endpoint, with a tenant whitelist and a rate-limit override |
| Permissions | derived from the granted endpoints' `RequiredPermission`, never from roles |
| Rate limiting | partitioned `{clientId}:{endpointCode}`, limit read from the catalogue row |
| Audit | every call on the surface, in two journals, with the truncated error body |
| Self-discovery | `GET /api/v1/me/access` |

**The seam is data, not code.** `DataApiAccessMiddleware` takes the **4th path
segment** as a catalogue code and resolves it in the database. So a client
extension publishes an API by serving `/api/v1/export/{code}` and seeding one
row — no platform change, no fork.

## The two silent failures

Everything this skill does is shaped by them:

1. **No catalogue row → 404 `endpoint_not_found`** for every external app,
   while a signed-in human calling the same URL succeeds. The endpoint looks
   shipped and is unreachable.
2. **Catalogue permission ≠ compiled constant → 403 `permission_mismatch`** on
   a perfectly formed call. The platform builds the app's claims from the
   catalogue row alone, so no amount of re-granting fixes it.

`audit-dev-external-api` (DEV-XAPI-001..014) exists to make both impossible to
ship.

## Grant grain — read this before choosing

The catalogue holds **one** `RequiredPermission` per code, and those exact
strings become the application's claims. One code serving GET+POST+PUT+DELETE
would therefore need a wildcard, which widens every existing grant the day a new
action is added to the section — and makes a read-only partner impossible, since
the code **is** the URL segment.

Default: **one code per operation, exact permissions.**

<!-- external-api-catalog:v1 -->
| Operation | Code | Verb | RequiredPermission | AccessType | Rate/min |
|---|---|---|---|---|---|
| read | `{app}-{section}` | GET | `{node}.read` | Read | 60 |
| create | `{app}-{section}-create` | POST | `{node}.create` | Write | 30 |
| update | `{app}-{section}-update` | PUT | `{node}.update` | Write | 30 |
| delete | `{app}-{section}-delete` | DELETE | `{node}.delete` | Write | 30 |
<!-- /external-api-catalog:v1 -->

`{node}` is the section-grain permission path `{app}.{module}.{section}`. Reads
keep the canonical URL; only writes carry a suffix. The opt-in
`granularity: 'resource'` collapses everything onto one code with a wildcard —
DEV-XAPI-013 warns, and says why.

`DataApiEndpoint.Code` is UNIQUE database-wide (100 chars max), so these codes
may never collide with the platform's own:

<!-- external-api-reserved:v1 -->
| Reserved code | Owner |
|---|---|
| `users` | platform (DataExportEndpointConfiguration.HasData) |
| `tenants` | platform (DataExportEndpointConfiguration.HasData) |
| `roles` | platform (DataExportEndpointConfiguration.HasData) |
| `tickets` | platform (DataExportEndpointConfiguration.HasData) |
| `navigation` | platform (DataExportEndpointConfiguration.HasData) |
| `workflows` | platform (DataExportEndpointConfiguration.HasData) |
<!-- /external-api-reserved:v1 -->

## The authoring channel

A public endpoint has **no screen**, so no pagespec can carry it — a pagespec
action is a button. The declaration lives in `entité.md`, beside the existing
`**API** : none` opt-out:

```markdown
- **API externe** : read, create
```

`— grain ressource` at the end switches the granularity. `aucune` is the
explicit opt-out. DEV-XAPI-010 checks the parity both ways: no endpoint without
a declaration, no declaration without an endpoint.

## Workflow

1. **Decide what to expose.** Read the module's `entité.md` and its pagespecs.
   Ask the user which resources a third party genuinely needs and in which
   direction — a public API is a commitment, not a side effect. Then run
   `derive-external-api-spec` in `check` mode to see what is already declared.

   The `section` it reports is the **menu section code**, resolved from the
   screen bound to the entity (or the module's single section) — never the
   entity's plural. That code is the catalogue code AND the permission path,
   i.e. the very constant `scaffold-controller` compiled into
   `{Mod}Permissions.{Section}`: a guess produces a spec that does not compile
   the moment a section groups two entities or carries a name of its own
   (`facturation` holding `Facture`). When it cannot be resolved the entity is
   **excluded from the spec** and reported — supply `section` on the
   declaration rather than letting anything be inferred.
2. **Persist the decision** — `derive-external-api-spec --mode write` writes the
   `**API externe**` lines back into `entité.md`. The BA tree stays the SSOT.
3. **Generate** — `scaffold-external-api` emits one controller per catalogue
   code, the catalogue seed provider and its DI registration.
4. **Build and boot once.** The provider upserts the catalogue rows at startup
   and self-heals at every boot — never wire it into `derive-seed-delta`.
5. **Audit** — `audit-dev-external-api`. Do not publish a contract over errors.
6. **Publish the contract** — `publish-api-contract` writes
   `.smartstack/api-public/v{n}/` (OpenAPI + Postman + a French integration
   guide) and refuses a breaking change without a version bump.
7. **Provision a test client** — `provision-external-app` creates the
   `ExternalApplication`, enables the grants and returns the secret **once**.
   Granting access to a real partner stays a deliberate admin act.

## Invocation

```bash
# 1. What is declared, what could be
npx --prefer-offline tsx skills/external-api/cli/derive-external-api-spec/index.ts \
  --spec '{"baRoot":".smartstack/ba","applicationCode":"crm","mode":"check"}'

# 2. Generate
npx --prefer-offline tsx skills/external-api/cli/scaffold-external-api/index.ts \
  --spec-file /tmp/external-api-spec.json --outdir "<repo-root>"

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

# 4. Publish the contract
npx --prefer-offline tsx skills/external-api/cli/publish-api-contract/index.ts \
  --spec '{"projectPath":"<repo-root>","applicationCode":"crm","version":"v1"}'

# 5. Provision a test client (needs a running API + an admin token)
npx --prefer-offline tsx skills/external-api/cli/provision-external-app/index.ts \
  --spec '{"baseUrl":"http://localhost:5000","adminToken":"…","name":"Partenaire logistique","codes":["crm-factures"]}'
```

Minimal `scaffold-external-api` spec:

```json
{
  "appCode": "TestV2",
  "applicationCode": "crm",
  "projectPath": "D:/app",
  "resources": [
    {
      "entity": "Facture",
      "module": "ventes",
      "section": "factures",
      "operations": ["read", "create"],
      "naturalKey": ["numero"],
      "fields": [
        { "name": "numero", "type": "string", "required": true },
        { "name": "montant", "type": "decimal", "required": true }
      ]
    }
  ]
}
```

## What you must tell the user

- **`?tenantId=` is mandatory on every call.** A machine caller has no ambient
  tenant. The platform validates the parameter against the application's tenant
  binding and the grant's whitelist before the action runs.
- **Permissions are frozen into the token** at exchange time and cached
  (`extapp-permissions-{appId}-{tenant}`): a new grant only takes effect at the
  next token renewal (`TokenExpirationMinutes`, 1–1440).
- **`Idempotency-Key` is inert here.** The platform scopes it on
  (TenantId, UserId) and both are null for an external app. Retry safety comes
  from a unique index on the natural key — SQL 2601/2627 is mapped to a 409.
- **No delta sync unless the query supports it.** `modifiedSince` is only
  emitted when `Get{Plural}Query` really carries the member; filtering a page
  after pagination would drop rows and lie about the total.

## Boundaries

- Never edit the platform (`SmartStack.app`): the whole design exists to avoid
  it. If something truly cannot be done from a client extension, say so and stop.
- Never create a grant for a real partner on your own — `provision-external-app`
  exists for test clients and is invoked deliberately.
- Never widen a grant to make a call pass. A 403 `permission_mismatch` is a
  catalogue/constant disagreement (DEV-XAPI-003), not a missing grant.
