import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const contract = JSON.parse(read('contracts/api-contract.json')); const fail = (message) => { console.error(`API contract check failed: ${message}`); process.exit(1); }; const dtoSources = ['server/src/WebDrop.Api/Json/ApiJson.cs', 'server/src/WebDrop.Core/Channels/Dtos.cs', 'server/src/WebDrop.Core/Channels/Settings.cs', 'server/src/WebDrop.Core/Common/Enums.cs', 'server/src/WebDrop.Core/Spaces/Workspace.cs', 'server/src/WebDrop.Core/Accounts/AccountProfile.cs', 'server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs', 'server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs', 'server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs']; const dtoDigest = createHash('sha256').update(dtoSources.map(path => `${path}\n${read(path).replace(/\r\n/g, '\n')}\n`).join('')).digest('hex'); const generatedDigest = /backend-contract-sha256: ([a-f0-9]{64})/.exec(read('web/src/api/generated.ts'))?.[1]; if (generatedDigest !== dtoDigest) fail('Backend DTOs changed. Regenerate with dotnet run --project tools/ContractGenerator -- .'); if (JSON.parse(read('contracts/api.schemas.json'))['x-backend-contract-sha256'] !== dtoDigest) fail('Generated API schemas are stale. Run ContractGenerator.'); if (typeof contract.version !== 'string' || !semver.test(contract.version)) { fail('contracts/api-contract.json must contain a SemVer version.'); } const apiMajor = Number(contract.version.split('.')[0]); const openapi = read('docs/openapi.yaml'); const openapiVersion = /^info:\s*\r?\n(?:.*\r?\n)*?\s+version:\s*([^\s#]+)/m.exec(openapi)?.[1]; if (openapiVersion !== contract.version) fail(`OpenAPI version ${openapiVersion ?? '(missing)'} differs from ${contract.version}.`); const backend = read('server/src/WebDrop.Api/Hosting/ApiContract.cs'); const backendVersion = /const string Version = "([^"]+)"/.exec(backend)?.[1]; if (backendVersion !== contract.version) fail(`backend version ${backendVersion ?? '(missing)'} differs from ${contract.version}.`); const frontend = read('web/src/api/contract.ts'); const supportedMajor = Number(/SUPPORTED_API_CONTRACT_MAJOR\s*=\s*(\d+)/.exec(frontend)?.[1]); if (supportedMajor !== apiMajor) { fail(`backend contract is ${contract.version}; frontend still supports API major ${supportedMajor || '(missing)'}. Update web/src/api/contract.ts.`); } const tag = process.argv[2]; if (tag !== undefined) { if (!tag.startsWith('v')) fail(`release tag ${tag} must start with v.`); const releaseVersion = tag.slice(1); if (!semver.test(releaseVersion) || releaseVersion.includes('+')) { fail(`release tag ${tag} must be a SemVer version usable as an OCI image tag (expected vMAJOR.MINOR.PATCH[-prerelease]).`); } } console.log(`API contract ${contract.version}: backend, OpenAPI, and frontend major ${supportedMajor} agree.`);