/** * cli:scaffold-external-api — generate.ts * * Emits the PUBLIC stratum of a client extension: the surface a third-party * system consumes machine-to-machine, under the ONE route prefix the platform's * `ExternalAppRouteGuardMiddleware` whitelists for an external-app principal. * * Per application it produces: * 1. one controller per catalogue code (`Controllers///Public/`), * 2. one `IClientSeedDataProvider` that upserts the `DataApiEndpoints` * catalogue rows idempotently at boot, * 3. the DI registration block for that provider. * * It emits NO permission constants: those already exist in * `{Mod}Permissions.{Section}` (written by `scaffold-controller`, whose nested * `{Section}` class is not partial — re-emitting it would be CS0101). The * public controller REUSES them, which is also what makes the catalogue row's * `RequiredPermission` and the compiled `[RequirePermission]` constant the same * string — the difference between 200 and a runtime 403 `permission_mismatch`. * * Business logic is never duplicated: every action calls the SAME * `I{Entity}Service` the two other strata call (DEV-API-014, extended here). */ import { applicationNs, controllersDir, controllersNs, moduleSegment, permissionsNs, sectionSegment, } from '../../../lib/app-classification.js' import { buildPublicApiCode, catalogRowsFor, type PublicApiCatalogRow, type PublicApiOperation, } from '../../../lib/external-api-catalog.js' import { capitalize, pluralize, toPascalCase } from '../../../lib/string-utils.js' import type { DiRegistration, GeneratedFile, GenerateResult, PublicApiField, PublicApiResource, ScaffoldExternalApiInput, } from './types.js' const DI_BEGIN = (appPascal: string) => ` // <<< PUBLIC-API-SEED-DI-${appPascal} BEGIN >>>` const DI_END = (appPascal: string) => ` // <<< PUBLIC-API-SEED-DI-${appPascal} END >>>` export function generate(spec: ScaffoldExternalApiInput): GenerateResult { const ns = spec.namespace ?? spec.appCode const appPascal = spec.applicationPascal ?? toPascalCase(spec.applicationCode) const files: GeneratedFile[] = [] const catalogue: GenerateResult['catalogue'] = [] const seedEntries: SeedEntry[] = [] for (const resource of spec.resources) { const rows = catalogRowsFor({ applicationCode: spec.applicationCode, moduleCode: resource.module, sectionCode: resource.section, entityName: resource.entity, operations: resource.operations, granularity: resource.granularity, maxPageSize: resource.maxPageSize, rateLimitPerMinute: resource.rateLimitPerMinute, }) for (const row of rows) { files.push(renderController(spec, ns, resource, row)) catalogue.push({ code: row.code, routeTemplate: row.routeTemplate, requiredPermission: row.requiredPermission, accessType: row.accessType, entity: resource.entity, verbs: [...row.httpVerbs], }) seedEntries.push({ row, resource }) } } files.push(renderCatalogueSeedProvider(spec, ns, appPascal, seedEntries)) return { files, diRegistration: renderDiRegistration(ns, appPascal), catalogue, } } interface SeedEntry { row: PublicApiCatalogRow resource: PublicApiResource } // ─── Controllers ──────────────────────────────────────────────────────────── /** * Class name per catalogue code. One class per code and NEVER several `[Route]` * attributes on one class: MVC would cross-product them, so the read action * would also answer on the write code's path — and `ResolveEndpointCode` would * then demand the write permission for a GET. */ function controllerClassName(plural: string, row: PublicApiCatalogRow): string { if (row.operations.length > 1) return `${plural}PublicController` const op = row.operations[0] return op === 'read' ? `${plural}PublicController` : `${plural}${toPascalCase(op)}PublicController` } function renderController( spec: ScaffoldExternalApiInput, ns: string, resource: PublicApiResource, row: PublicApiCatalogRow, ): GeneratedFile { const e = resource.entity const plural = resource.pluralName ?? pluralize(e) const mod = moduleSegment(resource.module) const section = sectionSegment(resource.section) const appNs = applicationNs(ns, spec.applicationCode, resource.module) const permNs = permissionsNs(ns, spec.applicationCode, resource.module) const ctrlNs = `${controllersNs(ns, spec.applicationCode, resource.module)}.Public` const ctrlDir = `${controllersDir(ns, spec.applicationCode, resource.module)}/Public` const className = controllerClassName(plural, row) const userFields = resource.fields.filter(f => !isSystemField(f.name)) const createFields = userFields.filter(f => f.required || !f.phase) const readCode = buildPublicApiCode(spec.applicationCode, resource.section, 'read', resource.granularity) const writes = row.operations.some(o => o !== 'read') const methods: string[] = [] if (row.operations.includes('read')) methods.push(readMethods(e, plural, mod, section, resource)) if (row.operations.includes('create')) methods.push(createMethod(e, mod, section, createFields, resource, readCode)) if (row.operations.includes('update')) methods.push(updateMethod(e, mod, section, userFields, resource)) if (row.operations.includes('delete')) methods.push(deleteMethod(mod, section)) const content = `// @generated-by scaffold-external-api — do NOT hand-edit. // // PUBLIC stratum — consumed by THIRD-PARTY systems, never by the SPA. // // Route: the literal below sits under /api/v1/export, the only prefix the // platform's ExternalAppRouteGuardMiddleware whitelists for an external-app // principal; every other path (the integration stratum /api/{module}/{section} // included) answers 403 route_blocked before the action runs. The 4th segment // IS the catalogue code: DataApiAccessMiddleware resolves it against // core.auth_DataExportEndpoints, checks the app's grant, its tenant whitelist // and the required permission, then audits the call. Renaming this route // without re-seeding the catalogue turns the endpoint into a 404 // endpoint_not_found. Never add a [NavRoute] here — the platform would rewrite // the route off the whitelist. using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using SmartStack.Api.Authorization; using SmartStack.Api.RateLimiting; using SmartStack.Application.Common.Interfaces.Identity; using SmartStack.Application.Common.Interfaces.Tenants; using SmartStack.Application.Platform.DataExport.DTOs; using SmartStack.Domain.Licensing; ${writes ? `using ${appNs}.Commands; ` : ''}using ${appNs}.DTOs; using ${appNs}.Interfaces; using ${appNs}.Queries; using ${permNs}; namespace ${ctrlNs}; /// /// Public API — ${e} (catalogue code ${row.code}, ${row.httpVerbs.join('/')}). /// ${resource.description ?? `Third-party access to ${plural}.`} /// Calls the SAME I${e}Service as the integration and screen strata — business /// rules are never duplicated across strata. /// [ApiController] [Route("${row.routeTemplate.replace(/^\//, '')}")] [ApiExplorerSettings(GroupName = "public")] [Microsoft.AspNetCore.Authorization.Authorize] [EnableRateLimiting(ExternalAppRateLimitExtensions.PolicyName)] [RequiresLicenseFeature(LicenseFeatures.ApiAccess)] [Tags("Public API - ${e}")] [Produces("application/json")] public class ${className} : ControllerBase { private readonly I${e}Service _service; private readonly ICurrentTenantService _tenant; private readonly ICurrentUserService _currentUser; public ${className}( I${e}Service service, ICurrentTenantService tenant, ICurrentUserService currentUser) { _service = service; _tenant = tenant; _currentUser = currentUser; } ${methods.join('\n')} ${tenantBinder()} } ` return { path: `${ctrlDir}/${className}.cs`, content } } /** * The tenant seam. An external app carries NO ambient tenant: the platform's * TenantResolutionMiddleware reads X-Tenant-Slug / the URL prefix, neither of * which a machine caller sets, so ICurrentTenantService.TenantId stays null and * every generated tenant query filter would run unscoped. `?tenantId=` is the * platform's own convention on this surface, and DataApiAccessMiddleware has * ALREADY validated it against the app's tenant binding and the grant's tenant * whitelist by the time the action runs — so binding it here is safe. * * A HUMAN caller is a different story: those two gates only run for an * external-app principal, so honouring an arbitrary tenantId for a signed-in * user would be a cross-tenant read. Hence the fail-closed branch below. */ function tenantBinder(): string { return ` /// /// Binds the request to the tenant named by ?tenantId=. Returns a /// result to short-circuit on, or null when the context is bound. /// private async Task BindTenantAsync(Guid tenantId, CancellationToken ct) { if (tenantId == Guid.Empty) { return BadRequest(new ProblemDetails { Title = "tenantId is required", Detail = "Every call on the public API must name the tenant it addresses.", Status = StatusCodes.Status400BadRequest, }); } if (_currentUser.IsExternalApp) { // The app's tenant binding and the grant's tenant whitelist were // already enforced by DataApiAccessMiddleware. if (!await _tenant.SetByIdAsync(tenantId, ct)) { return NotFound(new ProblemDetails { Title = "Tenant not found", Status = StatusCodes.Status404NotFound, }); } return null; } // Signed-in human: the external-app tenant gates never ran, so the only // tenant they may address is the one their session is already scoped to. if (_tenant.TenantId != tenantId) { return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Tenant scope denied", Detail = "A signed-in user may only address the tenant of the current session.", Status = StatusCodes.Status403Forbidden, }); } return null; }` } /** * `okType: null` emits the bodyless overload — `typeof(void)` does not compile. * Every error shape is ProblemDetails, so a third party can write ONE error * handler: the platform's GlobalExceptionHandler already answers that way. */ function producesBlock(okType: string | null, okStatus = 'Status200OK', extra: string[] = []): string { return [ okType === null ? ` [ProducesResponseType(StatusCodes.${okStatus})]` : ` [ProducesResponseType(typeof(${okType}), StatusCodes.${okStatus})]`, ' [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]', ' [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]', ' [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)]', ...extra.map(s => ` [ProducesResponseType(typeof(ProblemDetails), StatusCodes.${s})]`), ].join('\n') } function readMethods( e: string, plural: string, mod: string, section: string, resource: PublicApiResource, ): string { // Delta sync is only offered when Get{Plural}Query really carries the member: // filtering the page AFTER pagination would drop rows and lie about // TotalCount. Named argument — order-proof whatever the record grows. const modifiedSinceParam = resource.modifiedSinceFilter ? ' [FromQuery] DateTime? modifiedSince = null,\n' : '' const modifiedSinceArg = resource.modifiedSinceFilter ? ', ModifiedSince: modifiedSince' : '' return ` /// Paginated export of ${plural} for one tenant. [HttpGet] [RequirePermission(${mod}Permissions.${section}.Read)] ${producesBlock(`PaginatedExportResult<${e}ListDto>`, 'Status200OK', ['Status404NotFound'])} public async Task Export( [FromQuery] Guid tenantId, [FromQuery] int page = 1, [FromQuery] int pageSize = 100, [FromQuery] string? search = null, ${modifiedSinceParam} CancellationToken ct = default) { var denied = await BindTenantAsync(tenantId, ct); if (denied is not null) return denied; // The response is buffered whole by DataApiAccessMiddleware to capture // error bodies for the audit journal — the page cap is paid in RAM. pageSize = Math.Clamp(pageSize, 1, ${resource.maxPageSize}); page = Math.Max(1, page); var result = await _service.GetAllAsync(new Get${plural}Query(page, pageSize, search${modifiedSinceArg}), ct); return Ok(new PaginatedExportResult<${e}ListDto>( result.Items, result.Page, result.PageSize, result.TotalCount, result.TotalPages, result.HasNextPage)); } /// Single ${e} by id, scoped to the named tenant. [HttpGet("{id:guid}")] [RequirePermission(${mod}Permissions.${section}.Read)] ${producesBlock(`${e}DetailDto`, 'Status200OK', ['Status404NotFound'])} public async Task GetById(Guid id, [FromQuery] Guid tenantId, CancellationToken ct = default) { var denied = await BindTenantAsync(tenantId, ct); if (denied is not null) return denied; var result = await _service.GetByIdAsync(id, ct); return result is null ? NotFound(new ProblemDetails { Title = "${e} not found", Status = StatusCodes.Status404NotFound }) : Ok(result); } ` } /** * Create. * * Retry safety is NOT bought with an application-level probe — a * "does it already exist?" read followed by a write is a race, and it would * also have to guess which list filter carries the natural key. It comes from * the entity's UNIQUE INDEX: the platform's GlobalExceptionHandlerMiddleware * maps SQL 2601/2627 to a 409 Conflict. `naturalKey` in the spec is the * declaration DEV-XAPI-009 checks that index against. * * Note the platform's `IdempotencyMiddleware` is INERT here: it scopes the key * on (TenantId, UserId) and both are null for an external app, so an * `Idempotency-Key` header buys a machine caller nothing on this surface. */ function createMethod( e: string, mod: string, section: string, fields: PublicApiField[], resource: PublicApiResource, readCode: string, ): string { const mapping = fields.map(f => `dto.${capitalize(f.name)}`).join(', ') const keyNote = resource.naturalKey.length === 0 ? ` // No natural key declared: a retried POST creates a duplicate. Declare // naturalKey[] in the spec AND a unique index on the entity (DEV-XAPI-009).` : ` // A retried POST answers 409 through the unique index on // (${resource.naturalKey.join(', ')}) — SQL 2601/2627 is mapped to Conflict // by the platform's global exception handler.` return ` /// Creates one ${e} in the named tenant. [HttpPost] [RequirePermission(${mod}Permissions.${section}.Create)] ${producesBlock('Guid', 'Status201Created', ['Status409Conflict'])} public async Task Create( [FromQuery] Guid tenantId, [FromBody] Create${e}Dto dto, CancellationToken ct = default) { var denied = await BindTenantAsync(tenantId, ct); if (denied is not null) return denied; ${keyNote} var id = await _service.CreateAsync(new Create${e}Command(${mapping}), ct); // Location points at the READ code: creation and reading are different // catalogue codes, hence different controllers — nameof() cannot reach // across them. return Created($"/api/v1/export/${readCode}/{id}?tenantId={tenantId}", id); } ` } function updateMethod( e: string, mod: string, section: string, fields: PublicApiField[], resource: PublicApiResource, ): string { const mapping = ['id', ...fields.map(f => `dto.${capitalize(f.name)}`)].join(', ') + (resource.versioned ? ', dto.RowVersion' : '') return ` /// Updates one ${e} in the named tenant. [HttpPut("{id:guid}")] [RequirePermission(${mod}Permissions.${section}.Update)] ${producesBlock(null, 'Status204NoContent', ['Status404NotFound', 'Status409Conflict'])} public async Task Update( Guid id, [FromQuery] Guid tenantId, [FromBody] Update${e}Dto dto, CancellationToken ct = default) { var denied = await BindTenantAsync(tenantId, ct); if (denied is not null) return denied; await _service.UpdateAsync(new Update${e}Command(${mapping}), ct); return NoContent(); } ` } function deleteMethod(mod: string, section: string): string { return ` /// Deletes one entity in the named tenant. [HttpDelete("{id:guid}")] [RequirePermission(${mod}Permissions.${section}.Delete)] ${producesBlock(null, 'Status204NoContent', ['Status404NotFound'])} public async Task Delete(Guid id, [FromQuery] Guid tenantId, CancellationToken ct = default) { var denied = await BindTenantAsync(tenantId, ct); if (denied is not null) return denied; await _service.DeleteAsync(id, ct); return NoContent(); } ` } // ─── Catalogue seed provider ──────────────────────────────────────────────── /** * The catalogue row is what makes a public route reachable: without it the * middleware answers 404 `endpoint_not_found`, whatever the controller does. * * It cannot be built through `DataApiEndpoint.Create(...)` alone — that factory * leaves `NavigationApplicationId` / `NavigationModuleId` at Guid.Empty and both * are REQUIRED foreign keys (Restrict). The private setters are reachable * through the EF change tracker, which `ICoreDbContext.Entry()` exposes. * * Order 30 — after the Core providers (10..20), so the navigation rows this * resolves against already exist. Runs at EVERY boot and self-heals, so no * delta SQL is involved: never wire this into derive-seed-delta. */ function renderCatalogueSeedProvider( spec: ScaffoldExternalApiInput, ns: string, appPascal: string, entries: SeedEntry[], ): GeneratedFile { const className = `${appPascal}PublicApiCatalogSeedDataProvider` const providerNs = `${ns}.Infrastructure.Persistence.Seeding.Applications.${appPascal}.PublicApi` const dir = `src/${spec.appCode}.Infrastructure/Persistence/Seeding/Applications/${appPascal}/PublicApi` const rows = entries .map(({ row, resource }) => ` new( Code: "${row.code}", Name: "${escapeCs(row.name)}", Description: "${escapeCs(resource.description ?? `Public API for ${resource.entity}`)}", RouteTemplate: "${row.routeTemplate}", RequiredPermission: "${row.requiredPermission}", EntityType: "${resource.entity}", ModuleCode: "${resource.module}", AccessType: ApiEndpointAccessType.${row.accessType}, RateLimitPerMinute: ${row.defaultRateLimitPerMinute}, MaxPageSize: ${row.defaultMaxPageSize}),`) .join('\n') const content = `// @generated-by scaffold-external-api — do NOT hand-edit. using Microsoft.EntityFrameworkCore; using SmartStack.Application.Common.Interfaces.Persistence; using SmartStack.Application.Common.Interfaces.Seeding; using SmartStack.Domain.Platform.Administration.ExternalApplications; namespace ${providerNs}; /// /// Registers this application's PUBLIC API endpoints in the platform catalogue /// (core.auth_DataExportEndpoints). Without a row here the route exists but /// DataApiAccessMiddleware answers 404 endpoint_not_found for every external /// app, because it resolves the 4th path segment against this table. /// /// The row also carries the RequiredPermission the platform turns into the /// app's JWT claims (PermissionService.ResolveFromApiAccessAsync) — it must /// stay equal to the [RequirePermission] constant compiled onto the action, /// which DEV-XAPI-003 verifies statically. /// /// Idempotent and self-healing: runs at every boot, inserts what is missing and /// realigns what drifted. Granting an app access to these codes stays an /// explicit ADMIN act — this provider never creates an ExternalApplicationApiAccess. /// public class ${className} : IClientSeedDataProvider { // After the Core providers (10..20): the navigation application/module rows // this resolves against are seeded there. public int Order => 30; private sealed record CatalogueRow( string Code, string Name, string Description, string RouteTemplate, string RequiredPermission, string EntityType, string ModuleCode, ApiEndpointAccessType AccessType, int RateLimitPerMinute, int MaxPageSize); private static readonly CatalogueRow[] Rows = { ${rows} }; public Task SeedNavigationAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedRolesAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public Task SeedRolePermissionsAsync(ICoreDbContext context, CancellationToken ct = default) => Task.CompletedTask; public async Task SeedPermissionsAsync(ICoreDbContext context, CancellationToken ct = default) { if (Rows.Length == 0) return; var navApp = await context.NavigationApplications .FirstOrDefaultAsync(a => a.Code == "${spec.applicationCode}", ct); // No navigation yet (first boot ordering, or a partial install): skip // silently. A throw here would abort the whole seeding pass and the boot. if (navApp is null) return; var modules = await context.NavigationModules .Where(m => m.ApplicationId == navApp.Id) .ToListAsync(ct); var codes = Rows.Select(r => r.Code).ToArray(); var existing = await context.DataApiEndpoints .Where(e => codes.Contains(e.Code)) .ToListAsync(ct); var dirty = false; foreach (var row in Rows) { var navModule = modules.FirstOrDefault(m => m.Code == row.ModuleCode); if (navModule is null) continue; var current = existing.FirstOrDefault(e => e.Code == row.Code); if (current is null) { var endpoint = DataApiEndpoint.Create( code: row.Code, name: row.Name, routeTemplate: row.RouteTemplate, requiredPermission: row.RequiredPermission, entityType: row.EntityType, description: row.Description, defaultRateLimitPerMinute: row.RateLimitPerMinute, defaultMaxPageSize: row.MaxPageSize, accessType: row.AccessType); var added = context.DataApiEndpoints.Add(endpoint); // Both navigation FKs are REQUIRED and the factory does not set // them; their setters are private, so the change tracker is the // seam. Without this the insert fails the FK constraint. added.Property(nameof(DataApiEndpoint.NavigationApplicationId)).CurrentValue = navApp.Id; added.Property(nameof(DataApiEndpoint.NavigationModuleId)).CurrentValue = navModule.Id; dirty = true; continue; } // Realign a drifted row — a changed permission or route silently // 403s / 404s the third party until the catalogue catches up. var entry = context.Entry(current); if (current.RequiredPermission != row.RequiredPermission) { entry.Property(nameof(DataApiEndpoint.RequiredPermission)).CurrentValue = row.RequiredPermission; dirty = true; } if (current.RouteTemplate != row.RouteTemplate) { entry.Property(nameof(DataApiEndpoint.RouteTemplate)).CurrentValue = row.RouteTemplate; dirty = true; } if (current.AccessType != row.AccessType) { entry.Property(nameof(DataApiEndpoint.AccessType)).CurrentValue = row.AccessType; dirty = true; } if (!current.IsActive) { current.Activate(); dirty = true; } if (current.DefaultRateLimitPerMinute != row.RateLimitPerMinute || current.DefaultMaxPageSize != row.MaxPageSize) { current.UpdateDefaults(row.RateLimitPerMinute, row.MaxPageSize); dirty = true; } } if (dirty) await context.SaveChangesAsync(ct); } } ` return { path: `${dir}/${className}.cs`, content } } function renderDiRegistration(ns: string, appPascal: string): DiRegistration { const providerNs = `${ns}.Infrastructure.Persistence.Seeding.Applications.${appPascal}.PublicApi` const markerBlock = [ DI_BEGIN(appPascal), ` services.AddScoped();`, DI_END(appPascal), ].join('\n') return { markerBlock, candidatePaths: [ `src/${ns}.Infrastructure/DependencyInjection.cs`, `src/${ns}.Infrastructure/ServiceCollectionExtensions.cs`, ], } } export function diMarkers(appPascal: string): { begin: string; end: string } { return { begin: DI_BEGIN(appPascal).trim(), end: DI_END(appPascal).trim() } } /** * Controllers that a previous run emitted for operations no longer published. * Swept through `guardedRm`, so a file marked `@customised` survives. */ export function legacyPaths(spec: ScaffoldExternalApiInput): string[] { const ns = spec.namespace ?? spec.appCode const out: string[] = [] const allOps: PublicApiOperation[] = ['read', 'create', 'update', 'delete'] for (const resource of spec.resources) { const plural = resource.pluralName ?? pluralize(resource.entity) const dir = `${controllersDir(ns, spec.applicationCode, resource.module)}/Public` const kept = new Set( catalogRowsFor({ applicationCode: spec.applicationCode, moduleCode: resource.module, sectionCode: resource.section, entityName: resource.entity, operations: resource.operations, granularity: resource.granularity, }).map(row => controllerClassName(plural, row)), ) for (const op of allOps) { const name = op === 'read' ? `${plural}PublicController` : `${plural}${toPascalCase(op)}PublicController` if (!kept.has(name)) out.push(`${dir}/${name}.cs`) } } return out } // ─── small local helpers (mirrors of scaffold-controller's, kept local so the // two scaffolders stay independently deployable) ──────────────────────────── function isSystemField(name: string): boolean { const systemFields = ['id', 'createdat', 'updatedat', 'deletedat', 'createdby', 'updatedby', 'tenantid'] return systemFields.includes(name.toLowerCase()) } function escapeCs(s: string): string { return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') }