/** * cli:scaffold-external-api — validate.ts * * Fail-closed. Everything rejected here is something that would otherwise fail * at boot (a colliding catalogue code breaks the unique index and takes the * whole seeding pass down with it) or answer a silent runtime 403/404 to the * third party — the two failure modes this stratum is prone to. */ import { catalogRowsFor, validatePublicApiCode, } from '../../../lib/external-api-catalog.js' import { ScaffoldExternalApiInputSchema, type ValidationResult } from './types.js' export function validate(raw: unknown): ValidationResult { const parsed = ScaffoldExternalApiInputSchema.safeParse(raw) if (!parsed.success) { return { valid: false, errors: parsed.error.issues.map(i => `[${i.path.join('.')}] ${i.message}`), warnings: [], } } const spec = parsed.data const errors: string[] = [] const warnings: string[] = [] const seen = new Map() for (const resource of spec.resources) { const label = `${resource.entity} (${resource.module}/${resource.section})` 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) { const codeError = validatePublicApiCode(row.code, resource.section) if (codeError) errors.push(`${label}: ${codeError}`) const owner = seen.get(row.code) if (owner && owner !== label) { errors.push( `catalogue code "${row.code}" is claimed by both ${owner} and ${label} — DataApiEndpoint.Code is UNIQUE database-wide, so the boot seed would fail`, ) } seen.set(row.code, label) } // A write surface with no readable counterpart leaves the third party // unable to confirm what it wrote — and no id to address on update/delete. const writes = resource.operations.filter(o => o !== 'read') if (writes.length > 0 && !resource.operations.includes('read')) { errors.push( `${label}: publishes ${writes.join('/')} without read — a third party would have no way to read back what it wrote, nor any id to address`, ) } // Create/Update map the DTO members positionally onto the command record: // an empty field list emits `new CreateXCommand()`, which is CS7036. if ((resource.operations.includes('create') || resource.operations.includes('update')) && resource.fields.length === 0) { errors.push( `${label}: create/update published but fields[] is empty — the generated command call would have no arguments and fail to compile`, ) } if (resource.operations.includes('create') && resource.naturalKey.length === 0) { warnings.push( `${label}: create published without naturalKey[] — a retried POST creates a duplicate. Declare the natural key AND a unique index on the entity (DEV-XAPI-009).`, ) } for (const key of resource.naturalKey) { if (!resource.fields.some(f => f.name.toLowerCase() === key.toLowerCase())) { errors.push(`${label}: naturalKey member "${key}" is not among fields[]`) } } if (resource.granularity === 'resource' && writes.length > 0) { warnings.push( `${label}: granularity 'resource' with writes forces the wildcard permission "${rows[0]?.requiredPermission}" — one grant covers every verb, a read-only partner becomes impossible, and any action later added to the section is granted retroactively (DEV-XAPI-013).`, ) } if (!resource.modifiedSinceFilter) { warnings.push( `${label}: no modifiedSince filter — the third party can only page full snapshots. Declare the filter on the pagespec so it reaches Get${resource.pluralName ?? resource.entity + 's'}Query, then set modifiedSinceFilter: true.`, ) } } return { valid: errors.length === 0, errors, warnings } }