/** * lib/external-api-catalog.ts — Single source of truth for the PUBLIC stratum: * the surface a THIRD-PARTY system consumes machine-to-machine. * * The platform (SmartStack.app) already owns the whole M2M subsystem — client * identity (`ExternalApplication`), token exchange (HS256 assertion), per-app * grants, rate limiting, tenant scoping and a double audit journal. What it * exposes to a CLIENT EXTENSION is not a code seam but a DATA seam, and this * module pins its exact shape: * * 1. `ExternalAppRouteGuardMiddleware` whitelists `/api/v1/export` as a * SEGMENT PREFIX. An external-app principal calling any other path — the * integration stratum `/api/{module}/{section}` included — gets 403 * `route_blocked` BEFORE the controller runs. * 2. `DataApiAccessMiddleware.ResolveEndpointCode` takes the **4th path * segment** as the catalogue code and resolves it IN THE DATABASE * (`DataApiEndpoints.First(e => e.Code == code && e.IsActive)`). * Sub-routes (`/{id}`) fall back to the same code. * 3. `PermissionService.ResolveFromApiAccessAsync` derives the app's whole * permission set from the `RequiredPermission` of every endpoint it has an * enabled grant for — NOT from roles. Those strings become the JWT claims. * * Consequence (3) drives the grant-grain decision below, and it is the reason a * naive "one code, all verbs" design silently 403s on every write. * * `DataApiEndpoint.Code` carries a UNIQUE index (max 100 chars): a colliding * seed fails at boot, so the code grammar is a hard constraint, not a style. * * The `/external-api` SKILL.md carries these tables inline between * `` markers; `__tests__/external-api-catalog-drift.test.ts` * pins them to these exports — edit BOTH or the suite fails. */ /** Route prefix whitelisted by the platform's external-app route guard. */ export const PUBLIC_STRATUM_PREFIX = '/api/v1/export' /** `DataApiEndpoint.Code` — `HasMaxLength(100)` + unique index. */ export const CODE_MAX_LENGTH = 100 /** `DataApiEndpoint.RequiredPermission` — `HasMaxLength(256)`. */ export const REQUIRED_PERMISSION_MAX_LENGTH = 256 /** * `DataApiAccessMiddleware` buffers the FULL response into a MemoryStream to * capture error bodies for the audit journal, so page size is paid in RAM. * The platform's own export endpoints clamp at 1000 (500 for tickets). */ export const MAX_ALLOWED_PAGE_SIZE = 1000 /** `AccessType` column — `HasMaxLength(10)`, stored as the literal string. */ export type PublicApiAccessType = 'Read' | 'Write' /** The operation families a public resource can expose. */ export type PublicApiOperation = 'read' | 'create' | 'update' | 'delete' export interface PublicApiOperationSpec { readonly operation: PublicApiOperation /** Code suffix; `null` = the canonical resource code (reads keep the clean URL). */ readonly codeSuffix: string | null /** HTTP verbs this catalogue entry serves. */ readonly httpVerbs: readonly string[] /** Permission action, from `lib/permission-actions.ts` vocabulary. */ readonly permissionAction: string readonly accessType: PublicApiAccessType /** Platform default when the spec does not override it. */ readonly defaultRateLimitPerMinute: number } /** * ONE CATALOGUE CODE PER OPERATION — the default grant grain. * * The catalogue holds a SINGLE `RequiredPermission` per code, and those exact * strings become the app's claims. Serving GET+POST+PUT+DELETE from one code * would therefore force a wildcard (`{node}.*`), which `PermissionMatcher` * accepts but which (a) silently widens every existing grant the day a new * action is added to the section, and (b) makes a read-only partner impossible, * since the code IS the URL segment: two grants ⇒ two codes ⇒ two paths. * * Splitting by operation buys least privilege, per-operation revocation and a * per-operation rate limit, while the read surface keeps the canonical URL. */ export const PUBLIC_API_OPERATIONS: readonly PublicApiOperationSpec[] = [ { operation: 'read', codeSuffix: null, httpVerbs: ['GET'], permissionAction: 'read', accessType: 'Read', defaultRateLimitPerMinute: 60 }, { operation: 'create', codeSuffix: 'create', httpVerbs: ['POST'], permissionAction: 'create', accessType: 'Write', defaultRateLimitPerMinute: 30 }, { operation: 'update', codeSuffix: 'update', httpVerbs: ['PUT'], permissionAction: 'update', accessType: 'Write', defaultRateLimitPerMinute: 30 }, { operation: 'delete', codeSuffix: 'delete', httpVerbs: ['DELETE'], permissionAction: 'delete', accessType: 'Write', defaultRateLimitPerMinute: 30 }, ] /** Reserved code suffixes — a section code may never end with one (ambiguous parse). */ export const RESERVED_CODE_SUFFIXES: readonly string[] = PUBLIC_API_OPERATIONS .map(o => o.codeSuffix) .filter((s): s is string => s !== null) /** * Catalogue codes seeded by the platform itself * (`DataExportEndpointConfiguration.HasData`). `Code` is unique database-wide: * a client extension reusing one of these breaks the boot seed. */ export const SOCLE_RESERVED_CODES: readonly string[] = [ 'users', 'tenants', 'roles', 'tickets', 'navigation', 'workflows', ] /** Grant grain — how many catalogue codes a resource is split into. */ export type GrantGranularity = 'operation' | 'resource' export function operationSpec(operation: PublicApiOperation): PublicApiOperationSpec { const spec = PUBLIC_API_OPERATIONS.find(o => o.operation === operation) if (!spec) throw new Error(`unknown public API operation: ${operation}`) return spec } /** Which operation family serves an HTTP verb. */ export function operationForVerb(verb: string): PublicApiOperation | null { const upper = verb.toUpperCase() const spec = PUBLIC_API_OPERATIONS.find(o => o.httpVerbs.includes(upper)) return spec ? spec.operation : null } const CODE_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ /** * Build the catalogue code for a resource. * * buildPublicApiCode('crm', 'factures', 'read') → 'crm-factures' * buildPublicApiCode('crm', 'factures', 'create') → 'crm-factures-create' * * In `resource` granularity every operation collapses onto the bare code. */ export function buildPublicApiCode( applicationCode: string, sectionCode: string, operation: PublicApiOperation = 'read', granularity: GrantGranularity = 'operation', ): string { const base = `${applicationCode}-${sectionCode}` if (granularity === 'resource') return base const suffix = operationSpec(operation).codeSuffix return suffix ? `${base}-${suffix}` : base } export interface ParsedPublicApiCode { /** The code minus any operation suffix — the resource identity. */ readonly resourceCode: string /** `read` when no suffix is present. */ readonly operation: PublicApiOperation } /** * Split a catalogue code into resource + operation. The operation suffixes are * reserved (see `validatePublicApiCode`), so the parse is unambiguous even * though both the application and the section code may contain hyphens. */ export function parsePublicApiCode(code: string): ParsedPublicApiCode | null { if (!CODE_SEGMENT_RE.test(code)) return null for (const spec of PUBLIC_API_OPERATIONS) { if (spec.codeSuffix && code.endsWith(`-${spec.codeSuffix}`)) { const resourceCode = code.slice(0, -(spec.codeSuffix.length + 1)) if (!resourceCode) return null return { resourceCode, operation: spec.operation } } } return { resourceCode: code, operation: 'read' } } /** The URL a third party calls. `publicApiPath('crm-factures')` → `/api/v1/export/crm-factures`. */ export function publicApiPath(code: string): string { return `${PUBLIC_STRATUM_PREFIX}/${code}` } /** * Mirror of the platform's `ResolveEndpointCode`: the catalogue code is the * 4th path segment, and every sub-route resolves to the same code. * * codeFromPublicPath('/api/v1/export/crm-factures/{id:guid}') → 'crm-factures' * codeFromPublicPath('/api/screens/factures/list') → null */ export function codeFromPublicPath(path: string): string | null { const clean = path.split('?')[0].replace(/^\/+/, '/') if (!clean.toLowerCase().startsWith(`${PUBLIC_STRATUM_PREFIX}/`)) return null const segments = clean.split('/').filter(Boolean) return segments.length >= 4 ? segments[3] : null } /** * Fail-closed validation of a catalogue code. Returns an error message, or * `null` when the code is legal. */ export function validatePublicApiCode(code: string, sectionCode?: string): string | null { if (!code) return 'catalogue code is empty' if (code.length > CODE_MAX_LENGTH) { return `catalogue code "${code}" exceeds ${CODE_MAX_LENGTH} characters (DataApiEndpoint.Code is HasMaxLength(${CODE_MAX_LENGTH}))` } if (!CODE_SEGMENT_RE.test(code)) { return `catalogue code "${code}" is not kebab-case (expected {applicationCode}-{sectionCode}[-{operation}])` } if (SOCLE_RESERVED_CODES.includes(code)) { return `catalogue code "${code}" is seeded by the platform itself — DataApiEndpoint.Code is UNIQUE database-wide, the boot seed would collide` } if (sectionCode) { for (const suffix of RESERVED_CODE_SUFFIXES) { if (sectionCode.endsWith(`-${suffix}`) || sectionCode === suffix) { return `section code "${sectionCode}" ends with the reserved operation suffix "${suffix}" — the catalogue code would parse ambiguously` } } } return null } /** * Mirror of the platform's `PermissionMatcher.Matches` (exact match, global * `*`, prefix `x.*`). DEV-XAPI-003 uses it to prove, statically, that the * permission a catalogue row grants actually satisfies the `[RequirePermission]` * constant compiled onto the action — the difference between 200 and a runtime * 403 `permission_mismatch`. */ export function permissionMatches(userPermission: string, requiredPermission: string): boolean { if (userPermission.toLowerCase() === requiredPermission.toLowerCase()) return true if (userPermission === '*') return true if (userPermission.endsWith('.*')) { const prefix = userPermission.slice(0, -2) const lowerRequired = requiredPermission.toLowerCase() const lowerPrefix = prefix.toLowerCase() return lowerRequired.startsWith(`${lowerPrefix}.`) || lowerRequired === lowerPrefix } return false } /** One row of the `DataApiEndpoints` catalogue, as the seed provider writes it. */ export interface PublicApiCatalogRow { readonly code: string readonly name: string readonly routeTemplate: string /** * The permission the platform injects as a claim for any app granted this * endpoint. A MATCHER, not a permission path: never feed it to * `parsePermissionPath`, never seed it as a `NavigationPermission`. */ readonly requiredPermission: string readonly entityType: string readonly accessType: PublicApiAccessType readonly defaultRateLimitPerMinute: number readonly defaultMaxPageSize: number readonly operations: readonly PublicApiOperation[] readonly httpVerbs: readonly string[] } export interface PublicApiResourceSpec { readonly applicationCode: string readonly moduleCode: string readonly sectionCode: string readonly entityName: string /** Operations to expose; `read` is implied by any write in `resource` grain. */ readonly operations: readonly PublicApiOperation[] readonly granularity?: GrantGranularity readonly maxPageSize?: number readonly rateLimitPerMinute?: Partial> } /** The permission node path a public endpoint roots at — always SECTION grain. */ export function permissionNodePath(spec: { applicationCode: string moduleCode: string sectionCode: string }): string { return `${spec.applicationCode}.${spec.moduleCode}.${spec.sectionCode}` } /** * Derive the catalogue rows for one resource. This is the function the * scaffolder, the contract publisher and the audit all share, so the seeded * rows, the emitted controllers and the published contract cannot disagree. */ export function catalogRowsFor(spec: PublicApiResourceSpec): PublicApiCatalogRow[] { const granularity = spec.granularity ?? 'operation' const nodePath = permissionNodePath(spec) const maxPageSize = Math.min(spec.maxPageSize ?? MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_PAGE_SIZE) const ordered = PUBLIC_API_OPERATIONS.filter(o => spec.operations.includes(o.operation)) if (granularity === 'resource') { const code = buildPublicApiCode(spec.applicationCode, spec.sectionCode, 'read', 'resource') const verbs = ordered.flatMap(o => [...o.httpVerbs]) const hasWrite = ordered.some(o => o.accessType === 'Write') return [{ code, name: `${spec.entityName} (public API)`, routeTemplate: publicApiPath(code), // Wildcard: the ONLY way a single code can satisfy several action // constants. Widens with every action later added to the section. requiredPermission: hasWrite ? `${nodePath}.*` : `${nodePath}.read`, entityType: spec.entityName, accessType: hasWrite ? 'Write' : 'Read', defaultRateLimitPerMinute: spec.rateLimitPerMinute?.read ?? 60, defaultMaxPageSize: maxPageSize, operations: ordered.map(o => o.operation), httpVerbs: verbs, }] } return ordered.map(o => { const code = buildPublicApiCode(spec.applicationCode, spec.sectionCode, o.operation, 'operation') return { code, name: `${spec.entityName} — ${o.operation}`, routeTemplate: publicApiPath(code), requiredPermission: `${nodePath}.${o.permissionAction}`, entityType: spec.entityName, accessType: o.accessType, defaultRateLimitPerMinute: spec.rateLimitPerMinute?.[o.operation] ?? o.defaultRateLimitPerMinute, defaultMaxPageSize: maxPageSize, operations: [o.operation], httpVerbs: [...o.httpVerbs], } }) } export const EXTERNAL_API_CATALOG_MARKER = 'external-api-catalog:v1' export const EXTERNAL_API_RESERVED_MARKER = 'external-api-reserved:v1' /** Render the operation table carried inline by the `/external-api` SKILL.md. */ export function renderOperationTableBlock(): string { const header = '| Operation | Code | Verb | RequiredPermission | AccessType | Rate/min |' const sep = '|---|---|---|---|---|---|' const rows = PUBLIC_API_OPERATIONS.map(o => { const code = o.codeSuffix ? `{app}-{section}-${o.codeSuffix}` : '{app}-{section}' return `| ${o.operation} | \`${code}\` | ${o.httpVerbs.join(', ')} | \`{node}.${o.permissionAction}\` | ${o.accessType} | ${o.defaultRateLimitPerMinute} |` }) return [header, sep, ...rows].join('\n') } /** Render the reserved-code table carried inline by the `/external-api` SKILL.md. */ export function renderReservedCodesBlock(): string { const header = '| Reserved code | Owner |' const sep = '|---|---|' const rows = SOCLE_RESERVED_CODES.map(c => `| \`${c}\` | platform (DataExportEndpointConfiguration.HasData) |`) return [header, sep, ...rows].join('\n') } // ─── The BA authoring channel ─────────────────────────────────────────────── /** * A public endpoint has no screen, so no pagespec can declare it: a pagespec * action is a BUTTON. The declaration therefore lives in `entité.md`, next to * the existing `**API** : none` opt-out, and reads: * * - **API externe** : read, create — grain opération * * Absent line = the entity publishes nothing, and DEV-XAPI-010 refuses any * public controller that no line authorises (and any line no controller serves). */ export const EXTERNAL_API_MARKER_RE = /^\s*[-*]?\s*\*\*API\s+externe\*\*\s*:\s*(.+)$/im const OPERATION_ALIASES: Record = { read: 'read', lecture: 'read', lire: 'read', create: 'create', creation: 'create', création: 'create', creer: 'create', créer: 'create', update: 'update', maj: 'update', modification: 'update', modifier: 'update', delete: 'delete', suppression: 'delete', supprimer: 'delete', } export interface ExternalApiMarker { operations: PublicApiOperation[] granularity: GrantGranularity /** Tokens that matched no known operation — surfaced, never silently dropped. */ unknown: string[] } /** Parse the `**API externe**` line of an `entité.md`. Returns null when absent. */ export function parseExternalApiMarker(markdown: string): ExternalApiMarker | null { const m = markdown.match(EXTERNAL_API_MARKER_RE) if (!m) return null return parseExternalApiValue(m[1]) } /** * Parse the RIGHT-HAND SIDE of the marker — the shape `lib/ba-entities.ts` * hands over, since its generic field parser already folds every unknown * bullet into `entity.fields['api externe']`. Reading it there makes the * declaration ENTITY-grained, which is what it means: one module can publish * one of its entities and keep the others private. */ export function parseExternalApiValue(rhs: string): ExternalApiMarker { const raw = rhs.trim() if (raw === '' || /^(none|aucune|non)$/i.test(raw)) { return { operations: [], granularity: 'operation', unknown: [] } } const [listPart, ...rest] = raw.split(/[—–]|(?:\s-\s)/) const granularity: GrantGranularity = /ressource|resource/i.test(rest.join(' ')) ? 'resource' : 'operation' const operations: PublicApiOperation[] = [] const unknown: string[] = [] for (const token of listPart.split(/[,;/]/).map(t => t.trim()).filter(Boolean)) { const key = token.toLowerCase().replace(/[.]/g, '') const op = OPERATION_ALIASES[key] if (op) { if (!operations.includes(op)) operations.push(op) } else { unknown.push(token) } } return { operations, granularity, unknown } } /** Render the canonical marker line — the ONE remedy wording. */ export function renderExternalApiMarker( operations: readonly PublicApiOperation[], granularity: GrantGranularity = 'operation', ): string { const ops = PUBLIC_API_OPERATIONS.filter(o => operations.includes(o.operation)).map(o => o.operation) const suffix = granularity === 'resource' ? ' — grain ressource' : '' return `- **API externe** : ${ops.length === 0 ? 'aucune' : ops.join(', ')}${suffix}` }