diff --git a/README.md b/README.md index d338cf6..7c0d4fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## SCIBASE contribution modules + +- [Enterprise API Change Governance](enterprise-api-change-governance/README.md): contract-change review for institutional REST APIs and webhook integrations. diff --git a/enterprise-api-change-governance/README.md b/enterprise-api-change-governance/README.md new file mode 100644 index 0000000..2f69376 --- /dev/null +++ b/enterprise-api-change-governance/README.md @@ -0,0 +1,38 @@ +# Enterprise API Change Governance + +This module adds a focused Enterprise Tooling slice for institutional REST API and webhook contract governance. It helps university, institute, and corporate R&D admins decide whether an integration-facing change is ready to ship, needs review, should be held, or must be blocked before it breaks downstream systems. + +## What It Covers + +- Versioned REST route and webhook event change review. +- Breaking-change detection for removed fields, type changes, new required fields, and short deprecation windows. +- Critical consumer readiness checks for notice acknowledgement, sandbox evidence, migration tickets, and version negotiation. +- Restricted research data gates that require DPA evidence before funder or repository integrations receive sensitive payloads. +- Admin dashboard metrics, prioritized remediation actions, migration plans, export evidence manifests, and signed webhook review events. + +## Why This Is Distinct + +Existing Enterprise Tooling submissions cover broad dashboards, export packages, compliance evidence packets, audit routing, webhook replay, identity drift, retention/legal hold, data residency, SLA, secret rotation, lab inventory, and compute/storage quotas. This slice focuses specifically on contract-change governance before APIs and webhook schemas are released to enterprise consumers. + +## References Reviewed + +- OpenAPI-style machine-readable contract diffs for REST API route review. +- CloudEvents-style event envelopes for routing, replay, and event metadata consistency. +- Backward-compatible API evolution practices such as versioned releases, deprecation windows, and consumer migration evidence. + +## Local Usage + +```bash +npm run check +npm test +npm run demo +``` + +The demo writes: + +- `docs/demo.svg` +- `docs/governance-report.json` + +This PR also includes a short synthetic walkthrough video at `docs/demo.mp4`. + +The committed sample data is synthetic and does not contain credentials, private research data, bank details, or personally identifying documents. diff --git a/enterprise-api-change-governance/data/sample-change-plan.json b/enterprise-api-change-governance/data/sample-change-plan.json new file mode 100644 index 0000000..6c2ac9e --- /dev/null +++ b/enterprise-api-change-governance/data/sample-change-plan.json @@ -0,0 +1,165 @@ +{ + "portfolioId": "enterprise-api-governance-demo", + "asOf": "2026-05-17T02:30:00.000Z", + "policy": { + "minimumBreakingDeprecationDays": 90, + "criticalConsumerNoticeDays": 60, + "minimumRollbackDays": 14, + "requireOpenApiDiff": true, + "requireWebhookSchemaVersion": true, + "requireSandboxEvidence": true, + "requireDpaForRestrictedExports": true + }, + "integrations": [ + { + "integrationId": "dspace-archive-prod", + "institution": "Northbridge University Library", + "systemType": "repository", + "criticality": "critical", + "contactGroup": "library-platforms", + "pinnedApiVersions": ["v1"], + "subscribedWebhookTypes": ["project.published.v1", "export.completed.v1"], + "lastSuccessfulSandboxRun": "2026-05-05", + "supportsVersionNegotiation": true, + "notificationStatus": "acknowledged", + "migrationTicket": "NBUL-4421", + "hasDataProcessingAgreement": true + }, + { + "integrationId": "canvas-outcomes-sync", + "institution": "Westlake Medical School", + "systemType": "lms", + "criticality": "standard", + "contactGroup": "learning-systems", + "pinnedApiVersions": ["v2"], + "subscribedWebhookTypes": ["review.completed.v1"], + "lastSuccessfulSandboxRun": "2026-05-12", + "supportsVersionNegotiation": false, + "notificationStatus": "acknowledged", + "migrationTicket": "WMS-1180", + "hasDataProcessingAgreement": true + }, + { + "integrationId": "funder-reporter-nightly", + "institution": "Horizon Bioinformatics Institute", + "systemType": "funder_portal", + "criticality": "critical", + "contactGroup": "grants-ops", + "pinnedApiVersions": ["v1"], + "subscribedWebhookTypes": ["compliance.flagged.v1", "export.completed.v1"], + "lastSuccessfulSandboxRun": null, + "supportsVersionNegotiation": false, + "notificationStatus": "missing", + "migrationTicket": null, + "hasDataProcessingAgreement": false + } + ], + "changes": [ + { + "changeId": "api-project-export-v3", + "title": "Introduce v3 project export manifest with versioned metadata blocks", + "ownerTeam": "enterprise-integrations", + "surface": "rest_api", + "changeKind": "additive_version", + "currentVersion": "v2", + "proposedVersion": "v3", + "effectiveDate": "2026-09-01", + "affectedRoutes": ["GET /api/projects/{projectId}/exports", "POST /api/projects/{projectId}/exports"], + "affectedWebhookTypes": [], + "breaking": false, + "restrictedResearchData": false, + "openApiDiffAttached": true, + "rollbackPlanDays": 30, + "parallelRunDays": 120, + "schemaDiff": { + "removedFields": [], + "renamedFields": [], + "typeChanges": [], + "newRequiredFields": [], + "newOptionalFields": ["metadata.versionHistory", "metadata.repositoryTargets"] + }, + "webhookEnvelope": { + "usesCloudEvents": true, + "schemaVersionField": "data.schemaVersion", + "idempotencyKeyField": "data.deliveryId", + "signatureVersion": "v2" + }, + "sandboxEvidence": { + "fixturePack": "exports-v3-2026-05", + "passingIntegrations": ["dspace-archive-prod", "canvas-outcomes-sync"], + "failingIntegrations": [] + } + }, + { + "changeId": "api-review-score-removal", + "title": "Remove legacy peerReview.score field from review completed payloads", + "ownerTeam": "peer-review-platform", + "surface": "webhook", + "changeKind": "breaking_removal", + "currentVersion": "review.completed.v1", + "proposedVersion": "review.completed.v2", + "effectiveDate": "2026-06-15", + "affectedRoutes": [], + "affectedWebhookTypes": ["review.completed.v1"], + "breaking": true, + "restrictedResearchData": false, + "openApiDiffAttached": false, + "rollbackPlanDays": 3, + "parallelRunDays": 29, + "schemaDiff": { + "removedFields": ["peerReview.score"], + "renamedFields": ["peerReview.rubric -> peerReview.rubricBreakdown"], + "typeChanges": ["peerReview.reviewerCount:number -> string"], + "newRequiredFields": ["peerReview.decisionCode"], + "newOptionalFields": [] + }, + "webhookEnvelope": { + "usesCloudEvents": false, + "schemaVersionField": null, + "idempotencyKeyField": null, + "signatureVersion": null + }, + "sandboxEvidence": { + "fixturePack": null, + "passingIntegrations": [], + "failingIntegrations": ["canvas-outcomes-sync"] + } + }, + { + "changeId": "api-compliance-flag-pii", + "title": "Add restricted compliance flag webhook for funder reporting", + "ownerTeam": "compliance-ops", + "surface": "webhook", + "changeKind": "new_event", + "currentVersion": null, + "proposedVersion": "compliance.flagged.v2", + "effectiveDate": "2026-07-20", + "affectedRoutes": ["GET /api/compliance/flags"], + "affectedWebhookTypes": ["compliance.flagged.v2"], + "breaking": false, + "restrictedResearchData": true, + "openApiDiffAttached": true, + "rollbackPlanDays": 21, + "parallelRunDays": 90, + "schemaDiff": { + "removedFields": [], + "renamedFields": [], + "typeChanges": [], + "newRequiredFields": ["flag.category", "flag.evidenceDigest"], + "newOptionalFields": ["flag.funderMandateId"] + }, + "webhookEnvelope": { + "usesCloudEvents": true, + "schemaVersionField": "data.schemaVersion", + "idempotencyKeyField": "data.deliveryId", + "signatureVersion": "v2" + }, + "sandboxEvidence": { + "fixturePack": "compliance-v2-2026-05", + "passingIntegrations": ["dspace-archive-prod"], + "failingIntegrations": ["funder-reporter-nightly"] + } + } + ], + "signingKeyId": "synthetic-key-2026-05" +} diff --git a/enterprise-api-change-governance/docs/acceptance-notes.md b/enterprise-api-change-governance/docs/acceptance-notes.md new file mode 100644 index 0000000..f932ad3 --- /dev/null +++ b/enterprise-api-change-governance/docs/acceptance-notes.md @@ -0,0 +1,41 @@ +# Acceptance Notes + +This contribution targets issue `#19` by adding a distinct Enterprise Tooling module for API and webhook contract-change governance. + +## Review Scope + +- Self-contained dependency-free Node module. +- Synthetic data only. +- No external credentials, accounts, payment details, bank information, private research data, or KYC material. +- No changes to existing application code paths. + +## Distinctness + +This does not duplicate existing SCIBASE Enterprise Tooling submissions for: + +- admin dashboard foundations +- export packages +- compliance evidence packets +- audit signal routing +- webhook replay ledgers +- identity provisioning drift +- retention/legal hold +- grant portfolio compliance +- data residency +- SLA/uptime monitoring +- secret rotation +- lab inventory readiness +- compute/storage quota governance + +It covers a narrower pre-release governance layer: whether enterprise-facing API routes and webhook contracts can safely change without breaking institutional consumers. + +## Validation Commands + +```bash +cd enterprise-api-change-governance +npm run check +npm test +npm run demo +``` + +Demo video: `enterprise-api-change-governance/docs/demo.mp4`. diff --git a/enterprise-api-change-governance/docs/demo.mp4 b/enterprise-api-change-governance/docs/demo.mp4 new file mode 100644 index 0000000..4bfa356 Binary files /dev/null and b/enterprise-api-change-governance/docs/demo.mp4 differ diff --git a/enterprise-api-change-governance/docs/demo.svg b/enterprise-api-change-governance/docs/demo.svg new file mode 100644 index 0000000..33a20b6 --- /dev/null +++ b/enterprise-api-change-governance/docs/demo.svg @@ -0,0 +1,65 @@ + + + Enterprise API Change Governance demo + Dashboard preview for SCIBASE enterprise API and webhook compatibility review. + + + + + Enterprise API Change Governance + Versioned REST and webhook contract review for institutional integrations + + + Changes + 3 + + + + Blocked + 1 + + + + Critical Impacts + 1 + + + + Audit Digest + 793bd6d911b7dfb2499178e3d595bf52 + + + + + + Introduce v3 project export manifest with versioned metadata blocks + api-project-export-v3 - 1 affected integrations - no blockers + READY + risk 0 + + + + + Remove legacy peerReview.score field from review completed payloads + api-review-score-removal - 1 affected integrations - breaking-diff, removed-fields + BLOCKED + risk 100 + + + + + Add restricted compliance flag webhook for funder reporting + api-compliance-flag-pii - 1 affected integrations - new-required-fields, consumer-blockers + HOLD + risk 86 + + Outputs: admin actions, sandbox gates, migration notices, signed CloudEvents-style webhook evidence, export manifest. + diff --git a/enterprise-api-change-governance/docs/governance-report.json b/enterprise-api-change-governance/docs/governance-report.json new file mode 100644 index 0000000..4c0cb6d --- /dev/null +++ b/enterprise-api-change-governance/docs/governance-report.json @@ -0,0 +1,608 @@ +{ + "generatedAt": "2026-05-17T02:30:00.000Z", + "portfolioId": "enterprise-api-governance-demo", + "dashboard": { + "totalChanges": 3, + "byState": { + "ready": 1, + "watch": 0, + "hold": 1, + "blocked": 1 + }, + "blockedChanges": 1, + "holdOrBlockedChanges": 2, + "criticalIntegrationsImpacted": 1, + "consumerBlocks": 2, + "uniqueInstitutionsImpacted": 2, + "highestRiskScore": 100 + }, + "reviews": [ + { + "changeId": "api-project-export-v3", + "title": "Introduce v3 project export manifest with versioned metadata blocks", + "ownerTeam": "enterprise-integrations", + "surface": "rest_api", + "state": "ready", + "riskScore": 0, + "effectiveDate": "2026-09-01", + "breaking": false, + "compatibilityWindowDays": 120, + "affectedRoutes": [ + "GET /api/projects/{projectId}/exports", + "POST /api/projects/{projectId}/exports" + ], + "affectedWebhookTypes": [], + "affectedConsumers": [ + { + "integrationId": "canvas-outcomes-sync", + "institution": "Westlake Medical School", + "systemType": "lms", + "criticality": "standard", + "contactGroup": "learning-systems", + "readiness": "ready", + "blockers": [], + "warnings": [], + "migrationTicket": "WMS-1180", + "lastSuccessfulSandboxRun": "2026-05-12" + } + ], + "findings": [], + "adminActions": [ + "Approve api-project-export-v3 for enterprise rollout with monitored release notes." + ], + "migrationPlan": { + "changeId": "api-project-export-v3", + "noticeAudience": [ + "learning-systems" + ], + "sandboxFixturePack": "exports-v3-2026-05", + "rollbackPlanDays": 30, + "parallelRunDays": 120, + "requiredBeforeRelease": [ + "publish release note", + "monitor webhook deliveries" + ], + "suggestedOrder": [ + "publish contract diff and migration notes", + "run sandbox fixtures against affected integrations", + "collect critical-consumer acknowledgement", + "ship parallel version", + "monitor signed webhook evidence" + ] + }, + "evidenceDigest": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31" + }, + { + "changeId": "api-review-score-removal", + "title": "Remove legacy peerReview.score field from review completed payloads", + "ownerTeam": "peer-review-platform", + "surface": "webhook", + "state": "blocked", + "riskScore": 100, + "effectiveDate": "2026-06-15", + "breaking": true, + "compatibilityWindowDays": 29, + "affectedRoutes": [], + "affectedWebhookTypes": [ + "review.completed.v1" + ], + "affectedConsumers": [ + { + "integrationId": "canvas-outcomes-sync", + "institution": "Westlake Medical School", + "systemType": "lms", + "criticality": "standard", + "contactGroup": "learning-systems", + "readiness": "blocked", + "blockers": [ + "breaking change without version negotiation" + ], + "warnings": [], + "migrationTicket": "WMS-1180", + "lastSuccessfulSandboxRun": "2026-05-12" + } + ], + "findings": [ + { + "severity": "blocker", + "code": "breaking-diff", + "message": "Breaking changes must include a machine-readable diff for admins and integrators." + }, + { + "severity": "blocker", + "code": "removed-fields", + "message": "Removed fields detected: peerReview.score." + }, + { + "severity": "warning", + "code": "type-changes", + "message": "Type changes require fixture-backed migration notes: peerReview.reviewerCount:number -> string." + }, + { + "severity": "warning", + "code": "new-required-fields", + "message": "New required fields should ship behind a new version or default compatibility mode." + }, + { + "severity": "blocker", + "code": "short-deprecation-window", + "message": "Breaking change has 29 days of notice; policy requires 90." + }, + { + "severity": "blocker", + "code": "short-parallel-run", + "message": "Breaking changes need a parallel run window long enough for pinned institutional clients." + }, + { + "severity": "warning", + "code": "rollback-window", + "message": "Rollback plan covers 3 days; policy requires at least 14." + }, + { + "severity": "warning", + "code": "event-envelope", + "message": "Webhook changes should keep a standard event envelope for routing and replay." + }, + { + "severity": "blocker", + "code": "schema-version", + "message": "Webhook payloads need an explicit schema version field." + }, + { + "severity": "blocker", + "code": "idempotency-key", + "message": "Webhook payloads need a stable idempotency key for enterprise replay safety." + }, + { + "severity": "warning", + "code": "signature-version", + "message": "Webhook signing version should be explicit so consumers can rotate verifiers safely." + }, + { + "severity": "blocker", + "code": "consumer-blockers", + "message": "1 affected integrations are blocked." + } + ], + "adminActions": [ + "Block api-review-score-removal: Breaking changes must include a machine-readable diff for admins and integrators.", + "Block api-review-score-removal: Removed fields detected: peerReview.score.", + "Review api-review-score-removal: Type changes require fixture-backed migration notes: peerReview.reviewerCount:number -> string.", + "Review api-review-score-removal: New required fields should ship behind a new version or default compatibility mode.", + "Block api-review-score-removal: Breaking change has 29 days of notice; policy requires 90.", + "Block api-review-score-removal: Breaking changes need a parallel run window long enough for pinned institutional clients.", + "Review api-review-score-removal: Rollback plan covers 3 days; policy requires at least 14.", + "Review api-review-score-removal: Webhook changes should keep a standard event envelope for routing and replay.", + "Block api-review-score-removal: Webhook payloads need an explicit schema version field.", + "Block api-review-score-removal: Webhook payloads need a stable idempotency key for enterprise replay safety.", + "Review api-review-score-removal: Webhook signing version should be explicit so consumers can rotate verifiers safely.", + "Block api-review-score-removal: 1 affected integrations are blocked.", + "Open migration review for Westlake Medical School (canvas-outcomes-sync)." + ], + "migrationPlan": { + "changeId": "api-review-score-removal", + "noticeAudience": [ + "learning-systems" + ], + "sandboxFixturePack": null, + "rollbackPlanDays": 3, + "parallelRunDays": 29, + "requiredBeforeRelease": [ + "breaking-diff", + "removed-fields", + "short-deprecation-window", + "short-parallel-run", + "schema-version", + "idempotency-key", + "consumer-blockers" + ], + "suggestedOrder": [ + "publish contract diff and migration notes", + "run sandbox fixtures against affected integrations", + "collect critical-consumer acknowledgement", + "ship parallel version", + "monitor signed webhook evidence" + ] + }, + "evidenceDigest": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134" + }, + { + "changeId": "api-compliance-flag-pii", + "title": "Add restricted compliance flag webhook for funder reporting", + "ownerTeam": "compliance-ops", + "surface": "webhook", + "state": "hold", + "riskScore": 86, + "effectiveDate": "2026-07-20", + "breaking": false, + "compatibilityWindowDays": 90, + "affectedRoutes": [ + "GET /api/compliance/flags" + ], + "affectedWebhookTypes": [ + "compliance.flagged.v2" + ], + "affectedConsumers": [ + { + "integrationId": "funder-reporter-nightly", + "institution": "Horizon Bioinformatics Institute", + "systemType": "funder_portal", + "criticality": "critical", + "contactGroup": "grants-ops", + "readiness": "blocked", + "blockers": [ + "restricted-data integration missing DPA" + ], + "warnings": [ + "sandbox evidence is stale or absent", + "notification not acknowledged", + "no migration ticket linked" + ], + "migrationTicket": null, + "lastSuccessfulSandboxRun": null + } + ], + "findings": [ + { + "severity": "warning", + "code": "new-required-fields", + "message": "New required fields should ship behind a new version or default compatibility mode." + }, + { + "severity": "blocker", + "code": "consumer-blockers", + "message": "1 affected integrations are blocked." + }, + { + "severity": "warning", + "code": "sandbox-evidence", + "message": "1 affected integrations need fresh sandbox evidence." + }, + { + "severity": "warning", + "code": "notice-ack", + "message": "1 affected integrations have not acknowledged migration notice." + }, + { + "severity": "blocker", + "code": "restricted-data-dpa", + "message": "Restricted research data cannot flow to integrations without DPA evidence." + } + ], + "adminActions": [ + "Review api-compliance-flag-pii: New required fields should ship behind a new version or default compatibility mode.", + "Block api-compliance-flag-pii: 1 affected integrations are blocked.", + "Review api-compliance-flag-pii: 1 affected integrations need fresh sandbox evidence.", + "Review api-compliance-flag-pii: 1 affected integrations have not acknowledged migration notice.", + "Block api-compliance-flag-pii: Restricted research data cannot flow to integrations without DPA evidence.", + "Open migration review for Horizon Bioinformatics Institute (funder-reporter-nightly)." + ], + "migrationPlan": { + "changeId": "api-compliance-flag-pii", + "noticeAudience": [ + "grants-ops" + ], + "sandboxFixturePack": "compliance-v2-2026-05", + "rollbackPlanDays": 21, + "parallelRunDays": 90, + "requiredBeforeRelease": [ + "consumer-blockers", + "restricted-data-dpa" + ], + "suggestedOrder": [ + "publish contract diff and migration notes", + "run sandbox fixtures against affected integrations", + "collect critical-consumer acknowledgement", + "ship parallel version", + "monitor signed webhook evidence" + ] + }, + "evidenceDigest": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908" + } + ], + "exportManifest": { + "manifestId": "api-change-governance:enterprise-api-governance-demo", + "generatedFor": [ + "institutional-admin-dashboard", + "api-consumer-notice-pack", + "webhook-delivery-review", + "compliance-export-evidence" + ], + "readyChangeIds": [ + "api-project-export-v3" + ], + "reviewChangeIds": [ + "api-review-score-removal", + "api-compliance-flag-pii" + ], + "evidenceDigests": { + "api-project-export-v3": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31", + "api-review-score-removal": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134", + "api-compliance-flag-pii": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908" + } + }, + "webhookEvents": [ + { + "specversion": "1.0", + "type": "scibase.enterprise.api_change.ready", + "source": "/enterprise/api-change-governance", + "id": "58c5aa3ea0976ce9f7870fa7bea7d71f", + "subject": "api-project-export-v3", + "time": "2026-09-01", + "datacontenttype": "application/json", + "data": { + "changeId": "api-project-export-v3", + "state": "ready", + "riskScore": 0, + "affectedConsumers": 1, + "blockers": [], + "evidenceDigest": "a35bc73066e732b78bb0f760fe27a9869df9d57b002b82f71a0e8b989440aa31" + }, + "signatureKeyId": "synthetic-key-2026-05", + "signature": "9aa63e7edcd6aa9a141d40ad03748137f2f072e3734c427c0683468c8dd9b198" + }, + { + "specversion": "1.0", + "type": "scibase.enterprise.api_change.blocked", + "source": "/enterprise/api-change-governance", + "id": "3b5a0bc61fd4bc580f4eabd57824f9c2", + "subject": "api-review-score-removal", + "time": "2026-06-15", + "datacontenttype": "application/json", + "data": { + "changeId": "api-review-score-removal", + "state": "blocked", + "riskScore": 100, + "affectedConsumers": 1, + "blockers": [ + "breaking-diff", + "removed-fields", + "short-deprecation-window", + "short-parallel-run", + "schema-version", + "idempotency-key", + "consumer-blockers" + ], + "evidenceDigest": "52a3c00315c9a5db1fd4cd30a7c3e3273c90b275aa410900dc0e9912e57d0134" + }, + "signatureKeyId": "synthetic-key-2026-05", + "signature": "30f52b1549c748a69d37241d8e2ddc95a40932f0f2d1d48fb6e9c1f0afbf4268" + }, + { + "specversion": "1.0", + "type": "scibase.enterprise.api_change.hold", + "source": "/enterprise/api-change-governance", + "id": "48fe3ef563dd16d186da31cbd2cc1855", + "subject": "api-compliance-flag-pii", + "time": "2026-07-20", + "datacontenttype": "application/json", + "data": { + "changeId": "api-compliance-flag-pii", + "state": "hold", + "riskScore": 86, + "affectedConsumers": 1, + "blockers": [ + "consumer-blockers", + "restricted-data-dpa" + ], + "evidenceDigest": "b6d529654bab1df513308e97fd17297f4476afb80c08e768d54e28a72f94f908" + }, + "signatureKeyId": "synthetic-key-2026-05", + "signature": "b1fbb381c8766fc7e3ff91a0caf40e7c59ca6a0f8b4f900a0d5e2e90af96c334" + } + ], + "sanitizedInputEcho": { + "portfolioId": "enterprise-api-governance-demo", + "asOf": "2026-05-17T02:30:00.000Z", + "policy": { + "minimumBreakingDeprecationDays": 90, + "criticalConsumerNoticeDays": 60, + "minimumRollbackDays": 14, + "requireOpenApiDiff": true, + "requireWebhookSchemaVersion": true, + "requireSandboxEvidence": true, + "requireDpaForRestrictedExports": true + }, + "integrations": [ + { + "integrationId": "dspace-archive-prod", + "institution": "Northbridge University Library", + "systemType": "repository", + "criticality": "critical", + "contactGroup": "library-platforms", + "pinnedApiVersions": [ + "v1" + ], + "subscribedWebhookTypes": [ + "project.published.v1", + "export.completed.v1" + ], + "lastSuccessfulSandboxRun": "2026-05-05", + "supportsVersionNegotiation": true, + "notificationStatus": "acknowledged", + "migrationTicket": "NBUL-4421", + "hasDataProcessingAgreement": true + }, + { + "integrationId": "canvas-outcomes-sync", + "institution": "Westlake Medical School", + "systemType": "lms", + "criticality": "standard", + "contactGroup": "learning-systems", + "pinnedApiVersions": [ + "v2" + ], + "subscribedWebhookTypes": [ + "review.completed.v1" + ], + "lastSuccessfulSandboxRun": "2026-05-12", + "supportsVersionNegotiation": false, + "notificationStatus": "acknowledged", + "migrationTicket": "WMS-1180", + "hasDataProcessingAgreement": true + }, + { + "integrationId": "funder-reporter-nightly", + "institution": "Horizon Bioinformatics Institute", + "systemType": "funder_portal", + "criticality": "critical", + "contactGroup": "grants-ops", + "pinnedApiVersions": [ + "v1" + ], + "subscribedWebhookTypes": [ + "compliance.flagged.v1", + "export.completed.v1" + ], + "lastSuccessfulSandboxRun": null, + "supportsVersionNegotiation": false, + "notificationStatus": "missing", + "migrationTicket": null, + "hasDataProcessingAgreement": false + } + ], + "changes": [ + { + "changeId": "api-project-export-v3", + "title": "Introduce v3 project export manifest with versioned metadata blocks", + "ownerTeam": "enterprise-integrations", + "surface": "rest_api", + "changeKind": "additive_version", + "currentVersion": "v2", + "proposedVersion": "v3", + "effectiveDate": "2026-09-01", + "affectedRoutes": [ + "GET /api/projects/{projectId}/exports", + "POST /api/projects/{projectId}/exports" + ], + "affectedWebhookTypes": [], + "breaking": false, + "restrictedResearchData": false, + "openApiDiffAttached": true, + "rollbackPlanDays": 30, + "parallelRunDays": 120, + "schemaDiff": { + "removedFields": [], + "renamedFields": [], + "typeChanges": [], + "newRequiredFields": [], + "newOptionalFields": [ + "metadata.versionHistory", + "metadata.repositoryTargets" + ] + }, + "webhookEnvelope": { + "usesCloudEvents": true, + "schemaVersionField": "data.schemaVersion", + "idempotencyKeyField": "data.deliveryId", + "signatureVersion": "v2" + }, + "sandboxEvidence": { + "fixturePack": "exports-v3-2026-05", + "passingIntegrations": [ + "dspace-archive-prod", + "canvas-outcomes-sync" + ], + "failingIntegrations": [] + } + }, + { + "changeId": "api-review-score-removal", + "title": "Remove legacy peerReview.score field from review completed payloads", + "ownerTeam": "peer-review-platform", + "surface": "webhook", + "changeKind": "breaking_removal", + "currentVersion": "review.completed.v1", + "proposedVersion": "review.completed.v2", + "effectiveDate": "2026-06-15", + "affectedRoutes": [], + "affectedWebhookTypes": [ + "review.completed.v1" + ], + "breaking": true, + "restrictedResearchData": false, + "openApiDiffAttached": false, + "rollbackPlanDays": 3, + "parallelRunDays": 29, + "schemaDiff": { + "removedFields": [ + "peerReview.score" + ], + "renamedFields": [ + "peerReview.rubric -> peerReview.rubricBreakdown" + ], + "typeChanges": [ + "peerReview.reviewerCount:number -> string" + ], + "newRequiredFields": [ + "peerReview.decisionCode" + ], + "newOptionalFields": [] + }, + "webhookEnvelope": { + "usesCloudEvents": false, + "schemaVersionField": null, + "idempotencyKeyField": null, + "signatureVersion": null + }, + "sandboxEvidence": { + "fixturePack": null, + "passingIntegrations": [], + "failingIntegrations": [ + "canvas-outcomes-sync" + ] + } + }, + { + "changeId": "api-compliance-flag-pii", + "title": "Add restricted compliance flag webhook for funder reporting", + "ownerTeam": "compliance-ops", + "surface": "webhook", + "changeKind": "new_event", + "currentVersion": null, + "proposedVersion": "compliance.flagged.v2", + "effectiveDate": "2026-07-20", + "affectedRoutes": [ + "GET /api/compliance/flags" + ], + "affectedWebhookTypes": [ + "compliance.flagged.v2" + ], + "breaking": false, + "restrictedResearchData": true, + "openApiDiffAttached": true, + "rollbackPlanDays": 21, + "parallelRunDays": 90, + "schemaDiff": { + "removedFields": [], + "renamedFields": [], + "typeChanges": [], + "newRequiredFields": [ + "flag.category", + "flag.evidenceDigest" + ], + "newOptionalFields": [ + "flag.funderMandateId" + ] + }, + "webhookEnvelope": { + "usesCloudEvents": true, + "schemaVersionField": "data.schemaVersion", + "idempotencyKeyField": "data.deliveryId", + "signatureVersion": "v2" + }, + "sandboxEvidence": { + "fixturePack": "compliance-v2-2026-05", + "passingIntegrations": [ + "dspace-archive-prod" + ], + "failingIntegrations": [ + "funder-reporter-nightly" + ] + } + } + ], + "signingKeyId": "synthetic-key-2026-05" + }, + "auditDigest": "793bd6d911b7dfb2499178e3d595bf52b72796c83c3544a6a889df7b6848aeed" +} diff --git a/enterprise-api-change-governance/docs/requirement-map.md b/enterprise-api-change-governance/docs/requirement-map.md new file mode 100644 index 0000000..b66c188 --- /dev/null +++ b/enterprise-api-change-governance/docs/requirement-map.md @@ -0,0 +1,29 @@ +# Requirement Map + +## Admin Dashboards + +- `buildApiChangeGovernance()` emits `dashboard.totalChanges`, `dashboard.byState`, blocked-change counts, critical integration impacts, consumer blocks, and highest risk score. +- Each reviewed change includes a state, risk score, findings, and admin actions suitable for institutional admin queues. + +## API & Webhooks + +- REST API changes are gated by route lists, current/proposed versions, OpenAPI-style diff evidence, deprecation windows, rollback plans, and parallel run windows. +- Webhook changes are evaluated for event envelope consistency, schema version fields, idempotency keys, and signature-version evidence. +- `webhookEvents` emits signed CloudEvents-style governance events for downstream enterprise systems. + +## Export Pipelines + +- `exportManifest` groups ready vs. review-required changes and includes evidence digests for compliance export packets. +- Restricted research data changes are blocked when affected integrations do not have DPA evidence. + +## Enterprise Governance + +- Critical consumers require acknowledged notices before release. +- Sandbox fixture evidence is tracked for each affected institutional integration. +- Migration plans include notice audience, fixture packs, rollback windows, parallel run windows, and release sequencing. + +## Demo And Tests + +- `data/sample-change-plan.json` includes additive, breaking, and restricted-data rollout examples. +- `test/api-change-governance.test.js` covers ready, blocked, restricted-data hold, deterministic digest, signed events, and redaction behavior. +- `scripts/demo.js` prints a terminal summary and writes demo artifacts. diff --git a/enterprise-api-change-governance/package.json b/enterprise-api-change-governance/package.json new file mode 100644 index 0000000..0ab6c73 --- /dev/null +++ b/enterprise-api-change-governance/package.json @@ -0,0 +1,16 @@ +{ + "name": "enterprise-api-change-governance", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Enterprise API and webhook contract change governance for institutional SCIBASE integrations.", + "scripts": { + "check": "node --check src/api-change-governance.js && node --check scripts/demo.js && node --check test/api-change-governance.test.js", + "test": "node --test", + "demo": "node scripts/demo.js" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT" +} diff --git a/enterprise-api-change-governance/scripts/demo.js b/enterprise-api-change-governance/scripts/demo.js new file mode 100644 index 0000000..73b6a67 --- /dev/null +++ b/enterprise-api-change-governance/scripts/demo.js @@ -0,0 +1,109 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildApiChangeGovernance } from "../src/api-change-governance.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, ".."); +const sample = JSON.parse(await readFile(join(rootDir, "data", "sample-change-plan.json"), "utf8")); +const report = buildApiChangeGovernance(sample, { generatedAt: sample.asOf }); + +await mkdir(join(rootDir, "docs"), { recursive: true }); +await writeFile(join(rootDir, "docs", "demo.svg"), renderSvg(report)); +await writeFile(join(rootDir, "docs", "governance-report.json"), `${JSON.stringify(report, null, 2)}\n`); + +console.log("Enterprise API Change Governance"); +console.log(`Portfolio: ${report.portfolioId}`); +console.log(`Changes: ${report.dashboard.totalChanges}`); +console.log(`State distribution: ${JSON.stringify(report.dashboard.byState)}`); +console.log(`Critical integrations impacted: ${report.dashboard.criticalIntegrationsImpacted}`); +console.log(`Highest risk score: ${report.dashboard.highestRiskScore}`); +console.log(`Audit digest: ${report.auditDigest}`); + +for (const review of report.reviews) { + console.log(""); + console.log(`${review.state.toUpperCase()} ${review.riskScore} - ${review.title}`); + console.log(`Affected consumers: ${review.affectedConsumers.length}`); + console.log(`Evidence digest: ${review.evidenceDigest}`); + for (const action of review.adminActions.slice(0, 3)) { + console.log(`- ${action}`); + } +} + +function renderSvg(report) { + const rows = report.reviews.map((review, index) => { + const y = 232 + index * 112; + const color = stateColor(review.state); + const findings = review.findings + .slice(0, 2) + .map((finding) => finding.code) + .join(", ") || "no blockers"; + return ` + + + + ${escapeXml(review.title)} + ${review.changeId} - ${review.affectedConsumers.length} affected integrations - ${escapeXml(findings)} + ${review.state.toUpperCase()} + risk ${review.riskScore} + `; + }).join(""); + + return ` + + Enterprise API Change Governance demo + Dashboard preview for SCIBASE enterprise API and webhook compatibility review. + + + + + Enterprise API Change Governance + Versioned REST and webhook contract review for institutional integrations + + + Changes + ${report.dashboard.totalChanges} + + + + Blocked + ${report.dashboard.blockedChanges} + + + + Critical Impacts + ${report.dashboard.criticalIntegrationsImpacted} + + + + Audit Digest + ${report.auditDigest.slice(0, 32)} + + ${rows} + Outputs: admin actions, sandbox gates, migration notices, signed CloudEvents-style webhook evidence, export manifest. + +`; +} + +function stateColor(state) { + if (state === "ready") return "#0e9f6e"; + if (state === "watch") return "#d97706"; + if (state === "hold") return "#c2410c"; + return "#c81e1e"; +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/enterprise-api-change-governance/src/api-change-governance.js b/enterprise-api-change-governance/src/api-change-governance.js new file mode 100644 index 0000000..85ef534 --- /dev/null +++ b/enterprise-api-change-governance/src/api-change-governance.js @@ -0,0 +1,443 @@ +import { createHash, createHmac } from "node:crypto"; + +const DEFAULT_POLICY = Object.freeze({ + minimumBreakingDeprecationDays: 90, + criticalConsumerNoticeDays: 60, + minimumRollbackDays: 14, + requireOpenApiDiff: true, + requireWebhookSchemaVersion: true, + requireSandboxEvidence: true, + requireDpaForRestrictedExports: true +}); + +const STATE_ORDER = ["ready", "watch", "hold", "blocked"]; + +const SECRET_KEYS = new Set([ + "apiKey", + "clientSecret", + "privateToken", + "signingKey", + "taxId" +]); + +export function buildApiChangeGovernance(input, options = {}) { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const policy = { ...DEFAULT_POLICY, ...(input?.policy ?? {}) }; + const asOf = new Date(input?.asOf ?? generatedAt); + const integrations = Array.isArray(input?.integrations) ? input.integrations : []; + const changes = Array.isArray(input?.changes) ? input.changes : []; + + const reviews = changes.map((change) => evaluateChange(change, integrations, policy, asOf)); + const dashboard = buildDashboard(reviews); + const exportManifest = buildExportManifest(input?.portfolioId, reviews); + const webhookEvents = reviews.map((review) => buildWebhookEvent(review, input?.signingKeyId)); + const sanitizedInputEcho = redactSensitiveFields(input); + const digestPayload = { + portfolioId: input?.portfolioId ?? null, + asOf: input?.asOf ?? null, + dashboard, + exportManifest, + reviews: reviews.map(toDigestableReview), + webhookEvents: webhookEvents.map(({ signature, ...event }) => event), + sanitizedInputEcho + }; + + return { + generatedAt, + portfolioId: input?.portfolioId ?? "unknown-portfolio", + dashboard, + reviews, + exportManifest, + webhookEvents, + sanitizedInputEcho, + auditDigest: sha256(stableStringify(digestPayload)) + }; +} + +export function evaluateChange(change, integrations, policy = DEFAULT_POLICY, asOf = new Date()) { + const affectedConsumers = buildAffectedConsumers(change, integrations, policy, asOf); + const findings = [ + ...evaluateContractEvidence(change, policy), + ...evaluateDeprecation(change, policy, asOf), + ...evaluateWebhookEnvelope(change, policy), + ...evaluateConsumerReadiness(change, affectedConsumers, policy), + ...evaluateRestrictedData(change, affectedConsumers, policy) + ]; + const state = stateFromFindings(findings); + const adminActions = buildAdminActions(change, findings, affectedConsumers); + + return { + changeId: change.changeId, + title: change.title, + ownerTeam: change.ownerTeam ?? "unassigned", + surface: change.surface ?? "unknown", + state, + riskScore: riskScore(findings, affectedConsumers), + effectiveDate: change.effectiveDate ?? null, + breaking: Boolean(change.breaking), + compatibilityWindowDays: numberOrZero(change.parallelRunDays), + affectedRoutes: Array.isArray(change.affectedRoutes) ? change.affectedRoutes : [], + affectedWebhookTypes: Array.isArray(change.affectedWebhookTypes) ? change.affectedWebhookTypes : [], + affectedConsumers, + findings, + adminActions, + migrationPlan: buildMigrationPlan(change, affectedConsumers, findings), + evidenceDigest: sha256(stableStringify({ + changeId: change.changeId, + state, + findings, + affectedConsumers: affectedConsumers.map(toDigestableConsumer) + })) + }; +} + +export function redactSensitiveFields(value) { + if (Array.isArray(value)) { + return value.map(redactSensitiveFields); + } + + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + SECRET_KEYS.has(key) ? "[redacted]" : redactSensitiveFields(entry) + ]) + ); +} + +export function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function buildAffectedConsumers(change, integrations, policy, asOf) { + return integrations + .filter((integration) => isIntegrationAffected(change, integration)) + .map((integration) => { + const sandboxAgeDays = ageInDays(integration.lastSuccessfulSandboxRun, asOf); + const lacksFreshSandbox = sandboxAgeDays == null || sandboxAgeDays > 30; + const needsCriticalNotice = integration.criticality === "critical" + && daysUntil(change.effectiveDate, asOf) < policy.criticalConsumerNoticeDays; + const notificationMissing = integration.notificationStatus !== "acknowledged"; + const hasVersionRisk = Boolean(change.breaking) + && !integration.supportsVersionNegotiation + && !integration.pinnedApiVersions?.includes(change.proposedVersion); + const hasDpaGap = Boolean(change.restrictedResearchData) && !integration.hasDataProcessingAgreement; + const blockers = [ + hasVersionRisk ? "breaking change without version negotiation" : null, + hasDpaGap ? "restricted-data integration missing DPA" : null, + needsCriticalNotice && notificationMissing ? "critical consumer lacks acknowledged notice" : null + ].filter(Boolean); + const warnings = [ + lacksFreshSandbox ? "sandbox evidence is stale or absent" : null, + notificationMissing ? "notification not acknowledged" : null, + !integration.migrationTicket ? "no migration ticket linked" : null + ].filter(Boolean); + + return { + integrationId: integration.integrationId, + institution: integration.institution, + systemType: integration.systemType, + criticality: integration.criticality ?? "standard", + contactGroup: integration.contactGroup ?? "unknown", + readiness: blockers.length > 0 ? "blocked" : warnings.length > 0 ? "needs-review" : "ready", + blockers, + warnings, + migrationTicket: integration.migrationTicket ?? null, + lastSuccessfulSandboxRun: integration.lastSuccessfulSandboxRun ?? null + }; + }); +} + +function isIntegrationAffected(change, integration) { + const versions = integration.pinnedApiVersions ?? []; + const webhooks = integration.subscribedWebhookTypes ?? []; + const routeAffected = change.currentVersion && versions.includes(change.currentVersion); + const proposedVersionAffected = change.proposedVersion && versions.includes(change.proposedVersion); + const webhookAffected = (change.affectedWebhookTypes ?? []).some((type) => webhooks.includes(type)); + const newWebhookForSameFamily = (change.affectedWebhookTypes ?? []).some((type) => { + const family = type.split(".").slice(0, -1).join("."); + return webhooks.some((subscribed) => subscribed.startsWith(`${family}.`)); + }); + + return Boolean(routeAffected || proposedVersionAffected || webhookAffected || newWebhookForSameFamily); +} + +function evaluateContractEvidence(change, policy) { + const findings = []; + if (policy.requireOpenApiDiff && change.surface !== "webhook" && !change.openApiDiffAttached) { + findings.push(finding("blocker", "contract-diff", "OpenAPI or route contract diff is required before enterprise review.")); + } + + if (change.breaking && !change.openApiDiffAttached) { + findings.push(finding("blocker", "breaking-diff", "Breaking changes must include a machine-readable diff for admins and integrators.")); + } + + const diff = change.schemaDiff ?? {}; + if ((diff.removedFields ?? []).length > 0) { + findings.push(finding("blocker", "removed-fields", `Removed fields detected: ${diff.removedFields.join(", ")}.`)); + } + if ((diff.typeChanges ?? []).length > 0) { + findings.push(finding("warning", "type-changes", `Type changes require fixture-backed migration notes: ${diff.typeChanges.join(", ")}.`)); + } + if ((diff.newRequiredFields ?? []).length > 0 && change.changeKind !== "additive_version") { + findings.push(finding("warning", "new-required-fields", "New required fields should ship behind a new version or default compatibility mode.")); + } + + return findings; +} + +function evaluateDeprecation(change, policy, asOf) { + const findings = []; + const deprecationDays = daysUntil(change.effectiveDate, asOf); + const parallelRunDays = numberOrZero(change.parallelRunDays); + const rollbackPlanDays = numberOrZero(change.rollbackPlanDays); + + if (change.breaking && deprecationDays < policy.minimumBreakingDeprecationDays) { + findings.push(finding("blocker", "short-deprecation-window", `Breaking change has ${deprecationDays} days of notice; policy requires ${policy.minimumBreakingDeprecationDays}.`)); + } + if (change.breaking && parallelRunDays < policy.minimumBreakingDeprecationDays) { + findings.push(finding("blocker", "short-parallel-run", "Breaking changes need a parallel run window long enough for pinned institutional clients.")); + } + if (rollbackPlanDays < policy.minimumRollbackDays) { + findings.push(finding("warning", "rollback-window", `Rollback plan covers ${rollbackPlanDays} days; policy requires at least ${policy.minimumRollbackDays}.`)); + } + + return findings; +} + +function evaluateWebhookEnvelope(change, policy) { + const findings = []; + const envelope = change.webhookEnvelope ?? {}; + const touchesWebhooks = (change.affectedWebhookTypes ?? []).length > 0 || change.surface === "webhook"; + + if (!touchesWebhooks) { + return findings; + } + + if (!envelope.usesCloudEvents) { + findings.push(finding("warning", "event-envelope", "Webhook changes should keep a standard event envelope for routing and replay.")); + } + if (policy.requireWebhookSchemaVersion && !envelope.schemaVersionField) { + findings.push(finding("blocker", "schema-version", "Webhook payloads need an explicit schema version field.")); + } + if (!envelope.idempotencyKeyField) { + findings.push(finding("blocker", "idempotency-key", "Webhook payloads need a stable idempotency key for enterprise replay safety.")); + } + if (!envelope.signatureVersion) { + findings.push(finding("warning", "signature-version", "Webhook signing version should be explicit so consumers can rotate verifiers safely.")); + } + + return findings; +} + +function evaluateConsumerReadiness(change, affectedConsumers, policy) { + const findings = []; + const blockedConsumers = affectedConsumers.filter((consumer) => consumer.readiness === "blocked"); + const staleSandboxConsumers = affectedConsumers.filter((consumer) => + consumer.warnings.includes("sandbox evidence is stale or absent")); + const missingNoticeConsumers = affectedConsumers.filter((consumer) => + consumer.warnings.includes("notification not acknowledged")); + + if (blockedConsumers.length > 0) { + findings.push(finding("blocker", "consumer-blockers", `${blockedConsumers.length} affected integrations are blocked.`)); + } + if (policy.requireSandboxEvidence && staleSandboxConsumers.length > 0) { + findings.push(finding("warning", "sandbox-evidence", `${staleSandboxConsumers.length} affected integrations need fresh sandbox evidence.`)); + } + if (missingNoticeConsumers.length > 0) { + findings.push(finding("warning", "notice-ack", `${missingNoticeConsumers.length} affected integrations have not acknowledged migration notice.`)); + } + + return findings; +} + +function evaluateRestrictedData(change, affectedConsumers, policy) { + if (!policy.requireDpaForRestrictedExports || !change.restrictedResearchData) { + return []; + } + + const missingDpa = affectedConsumers.filter((consumer) => + consumer.blockers.includes("restricted-data integration missing DPA")); + if (missingDpa.length === 0) { + return []; + } + + return [finding("blocker", "restricted-data-dpa", "Restricted research data cannot flow to integrations without DPA evidence.")]; +} + +function buildDashboard(reviews) { + const byState = Object.fromEntries(STATE_ORDER.map((state) => [state, 0])); + for (const review of reviews) { + byState[review.state] += 1; + } + + const affectedConsumers = reviews.flatMap((review) => review.affectedConsumers); + return { + totalChanges: reviews.length, + byState, + blockedChanges: reviews.filter((review) => review.state === "blocked").length, + holdOrBlockedChanges: reviews.filter((review) => ["hold", "blocked"].includes(review.state)).length, + criticalIntegrationsImpacted: affectedConsumers.filter((consumer) => consumer.criticality === "critical").length, + consumerBlocks: affectedConsumers.filter((consumer) => consumer.readiness === "blocked").length, + uniqueInstitutionsImpacted: new Set(affectedConsumers.map((consumer) => consumer.institution)).size, + highestRiskScore: reviews.reduce((highest, review) => Math.max(highest, review.riskScore), 0) + }; +} + +function buildExportManifest(portfolioId, reviews) { + return { + manifestId: `api-change-governance:${portfolioId ?? "unknown"}`, + generatedFor: [ + "institutional-admin-dashboard", + "api-consumer-notice-pack", + "webhook-delivery-review", + "compliance-export-evidence" + ], + readyChangeIds: reviews.filter((review) => review.state === "ready").map((review) => review.changeId), + reviewChangeIds: reviews.filter((review) => review.state !== "ready").map((review) => review.changeId), + evidenceDigests: Object.fromEntries(reviews.map((review) => [review.changeId, review.evidenceDigest])) + }; +} + +function buildWebhookEvent(review, signingKeyId) { + const event = { + specversion: "1.0", + type: `scibase.enterprise.api_change.${review.state}`, + source: "/enterprise/api-change-governance", + id: sha256(`${review.changeId}:${review.state}:${review.evidenceDigest}`).slice(0, 32), + subject: review.changeId, + time: review.effectiveDate, + datacontenttype: "application/json", + data: { + changeId: review.changeId, + state: review.state, + riskScore: review.riskScore, + affectedConsumers: review.affectedConsumers.length, + blockers: review.findings.filter((item) => item.severity === "blocker").map((item) => item.code), + evidenceDigest: review.evidenceDigest + } + }; + const signaturePayload = stableStringify(event); + return { + ...event, + signatureKeyId: signingKeyId ?? "synthetic-signing-key", + signature: createHmac("sha256", signingKeyId ?? "synthetic-signing-key") + .update(signaturePayload) + .digest("hex") + }; +} + +function buildAdminActions(change, findings, affectedConsumers) { + const actions = findings.map((item) => { + if (item.severity === "blocker") return `Block ${change.changeId}: ${item.message}`; + return `Review ${change.changeId}: ${item.message}`; + }); + + for (const consumer of affectedConsumers) { + if (consumer.readiness === "blocked") { + actions.push(`Open migration review for ${consumer.institution} (${consumer.integrationId}).`); + } else if (consumer.readiness === "needs-review") { + actions.push(`Request sandbox acknowledgement from ${consumer.contactGroup} for ${consumer.integrationId}.`); + } + } + + if (actions.length === 0) { + actions.push(`Approve ${change.changeId} for enterprise rollout with monitored release notes.`); + } + + return [...new Set(actions)]; +} + +function buildMigrationPlan(change, affectedConsumers, findings) { + const blockers = findings.filter((item) => item.severity === "blocker").map((item) => item.code); + return { + changeId: change.changeId, + noticeAudience: affectedConsumers.map((consumer) => consumer.contactGroup), + sandboxFixturePack: change.sandboxEvidence?.fixturePack ?? null, + rollbackPlanDays: numberOrZero(change.rollbackPlanDays), + parallelRunDays: numberOrZero(change.parallelRunDays), + requiredBeforeRelease: blockers.length > 0 ? blockers : ["publish release note", "monitor webhook deliveries"], + suggestedOrder: [ + "publish contract diff and migration notes", + "run sandbox fixtures against affected integrations", + "collect critical-consumer acknowledgement", + "ship parallel version", + "monitor signed webhook evidence" + ] + }; +} + +function stateFromFindings(findings) { + const blockers = findings.filter((item) => item.severity === "blocker").length; + const warnings = findings.filter((item) => item.severity === "warning").length; + if (blockers >= 3) return "blocked"; + if (blockers > 0) return "hold"; + if (warnings > 0) return "watch"; + return "ready"; +} + +function riskScore(findings, affectedConsumers) { + const blockerScore = findings.filter((item) => item.severity === "blocker").length * 22; + const warningScore = findings.filter((item) => item.severity === "warning").length * 8; + const criticalScore = affectedConsumers.filter((consumer) => consumer.criticality === "critical").length * 6; + const blockedConsumerScore = affectedConsumers.filter((consumer) => consumer.readiness === "blocked").length * 12; + return Math.min(100, blockerScore + warningScore + criticalScore + blockedConsumerScore); +} + +function finding(severity, code, message) { + return { severity, code, message }; +} + +function daysUntil(dateString, asOf) { + if (!dateString) return 0; + const date = new Date(`${dateString}T00:00:00.000Z`); + return Math.max(0, Math.ceil((date.getTime() - asOf.getTime()) / 86_400_000)); +} + +function ageInDays(dateString, asOf) { + if (!dateString) return null; + const date = new Date(`${dateString}T00:00:00.000Z`); + return Math.max(0, Math.floor((asOf.getTime() - date.getTime()) / 86_400_000)); +} + +function numberOrZero(value) { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function toDigestableReview(review) { + return { + changeId: review.changeId, + state: review.state, + riskScore: review.riskScore, + findings: review.findings, + affectedConsumers: review.affectedConsumers.map(toDigestableConsumer), + evidenceDigest: review.evidenceDigest + }; +} + +function toDigestableConsumer(consumer) { + return { + integrationId: consumer.integrationId, + readiness: consumer.readiness, + blockers: consumer.blockers, + warnings: consumer.warnings + }; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/enterprise-api-change-governance/test/api-change-governance.test.js b/enterprise-api-change-governance/test/api-change-governance.test.js new file mode 100644 index 0000000..2c82a81 --- /dev/null +++ b/enterprise-api-change-governance/test/api-change-governance.test.js @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import sample from "../data/sample-change-plan.json" with { type: "json" }; +import { + buildApiChangeGovernance, + evaluateChange, + redactSensitiveFields, + stableStringify +} from "../src/api-change-governance.js"; + +describe("enterprise API change governance", () => { + it("approves additive versioned API changes with fresh sandbox evidence", () => { + const additive = sample.changes[0]; + const review = evaluateChange(additive, sample.integrations, sample.policy, new Date(sample.asOf)); + + assert.equal(review.state, "ready"); + assert.equal(review.breaking, false); + assert.ok(review.riskScore < 20); + assert.equal(review.findings.length, 0); + assert.ok(review.adminActions.some((action) => action.includes("Approve"))); + }); + + it("blocks breaking webhook removals without versioning, idempotency, or adequate notice", () => { + const breaking = sample.changes[1]; + const review = evaluateChange(breaking, sample.integrations, sample.policy, new Date(sample.asOf)); + const codes = review.findings.map((finding) => finding.code); + + assert.equal(review.state, "blocked"); + assert.ok(codes.includes("breaking-diff")); + assert.ok(codes.includes("removed-fields")); + assert.ok(codes.includes("short-deprecation-window")); + assert.ok(codes.includes("schema-version")); + assert.ok(codes.includes("idempotency-key")); + }); + + it("detects restricted-data DPA and sandbox gaps before funder webhook rollout", () => { + const restricted = sample.changes[2]; + const review = evaluateChange(restricted, sample.integrations, sample.policy, new Date(sample.asOf)); + const funder = review.affectedConsumers.find((consumer) => consumer.integrationId === "funder-reporter-nightly"); + + assert.equal(review.state, "hold"); + assert.equal(funder.readiness, "blocked"); + assert.ok(funder.blockers.includes("restricted-data integration missing DPA")); + assert.ok(review.adminActions.some((action) => action.includes("Open migration review"))); + }); + + it("builds deterministic dashboard, manifest, webhook events, and digest", () => { + const first = buildApiChangeGovernance(sample, { generatedAt: sample.asOf }); + const second = buildApiChangeGovernance(sample, { generatedAt: "2035-01-01T00:00:00.000Z" }); + + assert.equal(first.auditDigest, second.auditDigest); + assert.equal(first.dashboard.totalChanges, 3); + assert.deepEqual(first.dashboard.byState, { ready: 1, watch: 0, hold: 1, blocked: 1 }); + assert.equal(first.exportManifest.readyChangeIds.includes("api-project-export-v3"), true); + assert.equal(first.webhookEvents.length, 3); + assert.match(first.webhookEvents[0].signature, /^[a-f0-9]{64}$/); + }); + + it("redacts private credentials before echoing input into review artifacts", () => { + const redacted = redactSensitiveFields({ + apiKey: "secret-api-key", + nested: { signingKey: "secret-hmac-key", safe: "visible" } + }); + const serialized = stableStringify(redacted); + + assert.equal(serialized.includes("secret-api-key"), false); + assert.equal(serialized.includes("secret-hmac-key"), false); + assert.equal(serialized.includes("[redacted]"), true); + assert.equal(serialized.includes("visible"), true); + }); +});