---
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, and the published **contract** (`ContractJson`) |
| 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.

**That row also DESCRIBES the endpoint.** `ContractJson` carries the parameters it
accepts, the shape it answers, whether it paginates and — crucially — the HTTP
verbs it serves. The platform's admin surfaces (the external account's AI prompt,
the catalogue detail page, the Postman collection, the OpenAPI spec) render from
it, because none of them can know a client's endpoints in advance. Two members are
not documentation but behaviour:

| Member | Why it cannot be inferred |
|---|---|
| `httpMethods` | `AccessType` only says Read or Write, so `…-update` (PUT) and `…-delete` (DELETE) both read as "Write" and every consumer emitted **POST** — a 405 the integrator finds at runtime. |
| a REQUIRED `tenantId` | Every endpoint on this surface answers **400** without it. A collection that omits the parameter produces requests that fail on the first try. |

`scaffold-external-api` builds it from what the spec already declares (`fields`,
the operation's verbs, `maxPageSize`, `modifiedSinceFilter`) — you do not write it
by hand. It carries plain text and no translation key: the platform seeds a key
beside its OWN labels so they follow the signed-in language, but nobody can
translate a client's business label, and every consumer renders
`t(key, { defaultValue: description })`.

## 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.

**Sensitive entities** (`- **Sensibilité**` on the entity, `lib/ba-sensitivity`):
a `restreint` entity is NEVER exposed to a third party — the declaration is
refused (controlled `success:false`, DM-038 / DEV-XAPI-015). A `confidentiel`
entity is exposed only on the client's DATED decision, written on the line
itself:

```markdown
- **API externe** : read — grain opération — décision client du 2026-10-03 : export paie vers le prestataire
```

`derive-external-api-spec` reads the facet (`decision {date, reason}`), refuses
a confidentiel entity without it and a restreint one with or without it, and
records it in `write` mode from `declarations[].decision`. `public` and
`interne` entities publish on the existing line alone.

## 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.
   On a `confidentiel` entity the declaration carries the client's dated
   decision (`declarations[].decision = { date, reason }` → the `— décision
   client du …` facet); on a `restreint` entity it is refused — there is no
   public surface for restricted data, whatever the client says today.
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. The contract mirrors
  that decision, so the parameter is never advertised to a client whose handler
  would ignore it.
- **The published contract is what the admin screens read.** After a boot, the
  account's Prompt tab, the catalogue detail page and the downloadable
  Postman/OpenAPI files describe your endpoints from the seeded row. An endpoint
  with no contract still lists, is still grantable and still callable — it is
  simply undocumented, and every surface says so rather than rendering an empty
  section.

## 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.
