diff --git a/enterprise-webhook-replay-ledger/README.md b/enterprise-webhook-replay-ledger/README.md new file mode 100644 index 0000000..d2a47d4 --- /dev/null +++ b/enterprise-webhook-replay-ledger/README.md @@ -0,0 +1,68 @@ +# Enterprise Webhook Replay Ledger + +This module adds a focused enterprise integration slice for SCIBASE Enterprise +Tooling. It models signed webhook envelopes, institutional destinations, retry +windows, dead-letter queues, replay approvals, and delivery health metrics for +systems such as institutional repositories, learning management systems, +electronic lab notebooks, and funder reporting portals. + +The goal is narrow: help institutional admins prove that project publication, +review, compliance, and export events were delivered or are safely queued for +reviewable replay. + +## What It Covers + +- Creates deterministic event envelopes with idempotency keys and audit digests. +- Applies destination-specific retry, signing, retention, and replay policies. +- Classifies deliveries into delivered, retryable, dead-letter, replay-approved, + or manual-review states. +- Builds replay plans for failed institutional integrations. +- Produces admin dashboard metrics for webhook health and export readiness. +- Includes synthetic enterprise data, tests, a CLI demo, a requirement map, and + a short demo video. + +## Quick Start + +```bash +npm run check +npm test +npm run demo +``` + +Expected demo summary: + +```text +Enterprise delivery health: degraded +Dead-letter events: 2 +Replay-ready events: 1 +Manual review events: 1 +``` + +## Repository Layout + +```text +enterprise-webhook-replay-ledger/ + data/sample-enterprise-events.json + docs/demo.svg + docs/demo.mp4 + docs/requirement-map.md + scripts/demo.js + src/webhook-ledger.js + test/webhook-ledger.test.js +``` + +## Design Notes + +This is not a broad enterprise dashboard or another export package generator. +It is the reliability layer that sits behind those tools: + +1. SCIBASE emits a project, publication, review, compliance, or export event. +2. The ledger builds a signed, idempotent envelope for each configured + institutional destination. +3. Delivery attempts are evaluated against retry and replay policies. +4. Failed deliveries become dead-letter records with replay prerequisites. +5. Admin metrics expose whether enterprise integrations are healthy enough for + institutional reporting. + +The functions are pure and dependency-free, so they can back a REST endpoint, +webhook worker, nightly integration report, or pull request verification step. diff --git a/enterprise-webhook-replay-ledger/data/sample-enterprise-events.json b/enterprise-webhook-replay-ledger/data/sample-enterprise-events.json new file mode 100644 index 0000000..c4ab331 --- /dev/null +++ b/enterprise-webhook-replay-ledger/data/sample-enterprise-events.json @@ -0,0 +1,188 @@ +{ + "schemaVersion": "enterprise-webhook-ledger.v1", + "organization": { + "id": "northbridge-university", + "name": "Northbridge University Research Office", + "timezone": "UTC", + "dashboardWindowHours": 24 + }, + "destinations": [ + { + "id": "dspace-archive", + "name": "DSpace institutional repository", + "type": "institutional_repository", + "endpoint": "https://repo.example.edu/webhooks/scibase", + "signingKeyId": "northbridge-dspace-2026-05", + "maxAttempts": 4, + "retryBackoffMinutes": [5, 30, 120], + "replayRequiresApproval": false, + "retentionDays": 30, + "acceptedEventTypes": ["project.published", "dataset.exported", "doi.registered"] + }, + { + "id": "canvas-lms", + "name": "Canvas LMS research course sync", + "type": "learning_management_system", + "endpoint": "https://canvas.example.edu/scibase/events", + "signingKeyId": "northbridge-canvas-2026-05", + "maxAttempts": 3, + "retryBackoffMinutes": [10, 60], + "replayRequiresApproval": true, + "retentionDays": 14, + "acceptedEventTypes": ["project.created", "review.completed", "publication.submitted"] + }, + { + "id": "labnote-eln", + "name": "LabNote ELN enrichment", + "type": "electronic_lab_notebook", + "endpoint": "https://eln.example.edu/integrations/scibase", + "signingKeyId": "northbridge-eln-2026-05", + "maxAttempts": 5, + "retryBackoffMinutes": [5, 20, 60, 240], + "replayRequiresApproval": false, + "retentionDays": 45, + "acceptedEventTypes": ["reproducibility.score.updated", "dataset.exported"] + }, + { + "id": "grant-portal", + "name": "Funder mandate reporting portal", + "type": "funder_portal", + "endpoint": "https://funder.example.gov/reporting/scibase", + "signingKeyId": "northbridge-grant-2026-05", + "maxAttempts": 2, + "retryBackoffMinutes": [30], + "replayRequiresApproval": true, + "retentionDays": 60, + "acceptedEventTypes": ["compliance.mandate.failed", "compliance.mandate.passed"] + } + ], + "events": [ + { + "id": "evt-project-published-001", + "type": "project.published", + "occurredAt": "2026-05-15T08:05:00Z", + "projectId": "proj-neuro-organoid-atlas", + "actorId": "orcid:0000-0002-1825-0097", + "payload": { + "repositoryVersion": "preprint-v2.2", + "doi": "10.5555/scibase.neuro-organoid-atlas.v2", + "visibility": "public" + }, + "deliveries": [ + { + "destinationId": "dspace-archive", + "attempts": [ + { + "attemptedAt": "2026-05-15T08:05:30Z", + "statusCode": 202, + "durationMs": 184, + "responseDigest": "sha256:dspace-accepted-001" + } + ] + } + ] + }, + { + "id": "evt-review-completed-017", + "type": "review.completed", + "occurredAt": "2026-05-15T08:12:00Z", + "projectId": "proj-organic-sensor-array", + "actorId": "reviewer:pseudonym-r42", + "payload": { + "reviewTemplate": "engineering-design-check", + "decision": "changes_requested", + "visibility": "course-private" + }, + "deliveries": [ + { + "destinationId": "canvas-lms", + "attempts": [ + { + "attemptedAt": "2026-05-15T08:12:30Z", + "statusCode": 503, + "durationMs": 408, + "responseDigest": "sha256:canvas-503-a" + }, + { + "attemptedAt": "2026-05-15T08:22:40Z", + "statusCode": 503, + "durationMs": 390, + "responseDigest": "sha256:canvas-503-b" + }, + { + "attemptedAt": "2026-05-15T09:23:20Z", + "statusCode": 503, + "durationMs": 412, + "responseDigest": "sha256:canvas-503-c" + } + ], + "approvedForReplayBy": "enterprise-admin:northbridge-research-office", + "approvedForReplayAt": "2026-05-15T09:40:00Z" + } + ] + }, + { + "id": "evt-dataset-exported-022", + "type": "dataset.exported", + "occurredAt": "2026-05-15T09:00:00Z", + "projectId": "proj-neuro-organoid-atlas", + "actorId": "orcid:0000-0002-1825-0097", + "payload": { + "exportTarget": "Zenodo", + "datasetHash": "sha256:dataset-archive-v4", + "license": "CC-BY-4.0" + }, + "deliveries": [ + { + "destinationId": "labnote-eln", + "attempts": [ + { + "attemptedAt": "2026-05-15T09:00:20Z", + "statusCode": 429, + "durationMs": 122, + "responseDigest": "sha256:eln-rate-limit" + }, + { + "attemptedAt": "2026-05-15T09:05:50Z", + "statusCode": 200, + "durationMs": 240, + "responseDigest": "sha256:eln-delivered" + } + ] + } + ] + }, + { + "id": "evt-compliance-failed-006", + "type": "compliance.mandate.failed", + "occurredAt": "2026-05-15T09:30:00Z", + "projectId": "proj-climate-sensor-network", + "actorId": "system:compliance-engine", + "payload": { + "funder": "Horizon EU", + "mandate": "open-data-within-30-days", + "missingEvidence": ["data-availability-statement", "repository-link"] + }, + "deliveries": [ + { + "destinationId": "grant-portal", + "attempts": [ + { + "attemptedAt": "2026-05-15T09:30:20Z", + "statusCode": 401, + "durationMs": 155, + "responseDigest": "sha256:grant-auth-expired" + }, + { + "attemptedAt": "2026-05-15T10:01:00Z", + "statusCode": 401, + "durationMs": 142, + "responseDigest": "sha256:grant-auth-expired-repeat" + } + ] + } + ] + } + ], + "evaluationTime": "2026-05-15T10:10:00Z" +} diff --git a/enterprise-webhook-replay-ledger/docs/demo.mp4 b/enterprise-webhook-replay-ledger/docs/demo.mp4 new file mode 100644 index 0000000..eca42b2 Binary files /dev/null and b/enterprise-webhook-replay-ledger/docs/demo.mp4 differ diff --git a/enterprise-webhook-replay-ledger/docs/demo.svg b/enterprise-webhook-replay-ledger/docs/demo.svg new file mode 100644 index 0000000..d1864ef --- /dev/null +++ b/enterprise-webhook-replay-ledger/docs/demo.svg @@ -0,0 +1,55 @@ + + Enterprise webhook replay ledger demo + Dashboard showing degraded enterprise webhook health with dead-letter and replay-ready events. + + + Enterprise Webhook Replay Ledger + Northbridge University integration health window + + DEGRADED + + + Deliveries + 4 + events evaluated + + + Dead-letter + 2 + failed delivery records + + + Replay-ready + 1 + approved envelope + + + Manual review + 1 + approval needed + + + Institutional destinations + + + + DSpace repository + project.published accepted with signed idempotent envelope. + + delivered + + + Canvas LMS + three 503 responses, replay approved by enterprise admin. + + replay ready + + + Funder portal + 401 responses require approval before replay leaves dead-letter queue. + + manual review + + + Replay plan: preserve idempotency key, body digest, signing key id, and retention deadline for each failed enterprise event. + diff --git a/enterprise-webhook-replay-ledger/docs/requirement-map.md b/enterprise-webhook-replay-ledger/docs/requirement-map.md new file mode 100644 index 0000000..b6e5720 --- /dev/null +++ b/enterprise-webhook-replay-ledger/docs/requirement-map.md @@ -0,0 +1,41 @@ +# Requirement Map + +Issue: SCIBASE-AI/SCIBASE.AI#19, Enterprise Tooling. + +## Admin Dashboards + +The ledger emits delivery health, dead-letter counts, replay-ready counts, +manual-review counts, retry state, and per-destination delivery evidence. These +values can populate an institutional admin dashboard for integration and export +reliability. + +## API & Webhooks + +Each event is wrapped in a deterministic envelope with a destination id, +idempotency key, signing key id, body digest, and signature preview. The sample +destinations cover institutional repositories, learning management systems, +electronic lab notebooks, and funder portals. + +## Webhook Reliability + +Destination policies define accepted event types, retry windows, max attempts, +replay approval requirements, and retention. Failed attempts are classified into +retryable, dead-letter, replay-approved, or manual-review states. + +## Export Pipelines + +Dataset export, project publication, DOI registration, review completion, and +compliance mandate events can be delivered to downstream systems. Replay plans +preserve the same idempotency key so external systems do not duplicate exports. + +## Compliance And Reporting + +The funder portal sample demonstrates compliance mandate reporting with a +manual-review hold when replay needs enterprise approval. Audit digests make the +delivery state reproducible for research-office reporting. + +## Scope Boundary + +This is not another broad enterprise dashboard, export package generator, +trust-center module, or compliance evidence packet. It is a reliability and +replay layer for enterprise webhook delivery. diff --git a/enterprise-webhook-replay-ledger/package.json b/enterprise-webhook-replay-ledger/package.json new file mode 100644 index 0000000..620e664 --- /dev/null +++ b/enterprise-webhook-replay-ledger/package.json @@ -0,0 +1,15 @@ +{ + "name": "enterprise-webhook-replay-ledger", + "version": "0.1.0", + "description": "Dependency-free delivery ledger for SCIBASE enterprise webhooks and export events.", + "private": true, + "scripts": { + "check": "node scripts/demo.js --json > /dev/null", + "demo": "node scripts/demo.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/enterprise-webhook-replay-ledger/scripts/demo.js b/enterprise-webhook-replay-ledger/scripts/demo.js new file mode 100644 index 0000000..20f4e16 --- /dev/null +++ b/enterprise-webhook-replay-ledger/scripts/demo.js @@ -0,0 +1,17 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { createWebhookLedger, formatLedgerReport } = require("../src/webhook-ledger"); + +const manifestPath = path.join(__dirname, "..", "data", "sample-enterprise-events.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const ledger = createWebhookLedger(manifest); + +if (process.argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(ledger, null, 2)}\n`); +} else { + process.stdout.write(`${formatLedgerReport(ledger)}\n`); +} + +if (ledger.health === "invalid-manifest") { + process.exitCode = 1; +} diff --git a/enterprise-webhook-replay-ledger/src/webhook-ledger.js b/enterprise-webhook-replay-ledger/src/webhook-ledger.js new file mode 100644 index 0000000..cc18ff4 --- /dev/null +++ b/enterprise-webhook-replay-ledger/src/webhook-ledger.js @@ -0,0 +1,357 @@ +const crypto = require("node:crypto"); + +const SUCCESS_STATUS_MIN = 200; +const SUCCESS_STATUS_MAX = 299; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).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 digest(value) { + return `sha256:${crypto.createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function buildDestinationIndex(manifest) { + return new Map((manifest.destinations || []).map((destination) => [destination.id, destination])); +} + +function isSuccess(statusCode) { + return statusCode >= SUCCESS_STATUS_MIN && statusCode <= SUCCESS_STATUS_MAX; +} + +function isRetryableStatus(statusCode) { + return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500; +} + +function assertIsoDate(value, label, errors) { + if (!value || Number.isNaN(Date.parse(value))) { + errors.push(`${label} must be an ISO timestamp`); + } +} + +function validateManifest(manifest) { + const errors = []; + if (manifest.schemaVersion !== "enterprise-webhook-ledger.v1") { + errors.push("schemaVersion must be enterprise-webhook-ledger.v1"); + } + if (!manifest.organization?.id) { + errors.push("organization.id is required"); + } + assertIsoDate(manifest.evaluationTime, "evaluationTime", errors); + + const destinationIds = new Set(); + for (const destination of manifest.destinations || []) { + if (!destination.id) { + errors.push("every destination requires an id"); + } + if (destinationIds.has(destination.id)) { + errors.push(`duplicate destination id: ${destination.id}`); + } + destinationIds.add(destination.id); + if (!destination.endpoint?.startsWith("https://")) { + errors.push(`destination ${destination.id} requires an https endpoint`); + } + if (!destination.signingKeyId) { + errors.push(`destination ${destination.id} requires a signingKeyId`); + } + if (!Number.isInteger(destination.maxAttempts) || destination.maxAttempts < 1) { + errors.push(`destination ${destination.id} requires maxAttempts >= 1`); + } + } + + for (const event of manifest.events || []) { + if (!event.id || !event.type) { + errors.push("every event requires id and type"); + } + assertIsoDate(event.occurredAt, `${event.id}.occurredAt`, errors); + for (const delivery of event.deliveries || []) { + if (!destinationIds.has(delivery.destinationId)) { + errors.push(`event ${event.id} references missing destination ${delivery.destinationId}`); + } + for (const attempt of delivery.attempts || []) { + assertIsoDate(attempt.attemptedAt, `${event.id}.${delivery.destinationId}.attemptedAt`, errors); + if (!Number.isInteger(attempt.statusCode)) { + errors.push(`event ${event.id}.${delivery.destinationId} attempt requires integer statusCode`); + } + } + if (delivery.approvedForReplayAt) { + assertIsoDate(delivery.approvedForReplayAt, `${event.id}.${delivery.destinationId}.approvedForReplayAt`, errors); + } + } + } + + return errors; +} + +function buildEnvelope(manifest, event, destination) { + const body = { + organizationId: manifest.organization.id, + destinationId: destination.id, + eventId: event.id, + eventType: event.type, + occurredAt: event.occurredAt, + projectId: event.projectId, + actorId: event.actorId, + payload: event.payload + }; + + const idempotencyKey = digest({ + organizationId: manifest.organization.id, + destinationId: destination.id, + eventId: event.id, + eventType: event.type + }).slice("sha256:".length, "sha256:".length + 24); + + return { + id: `${destination.id}:${event.id}`, + idempotencyKey, + signingKeyId: destination.signingKeyId, + bodyDigest: digest(body), + signaturePreview: digest({ + signingKeyId: destination.signingKeyId, + bodyDigest: digest(body) + }).slice(0, 24), + body + }; +} + +function nextRetryAt(delivery, destination) { + const attempts = delivery.attempts || []; + if (attempts.length === 0 || attempts.length >= destination.maxAttempts) { + return null; + } + + const lastAttempt = attempts.at(-1); + const backoff = destination.retryBackoffMinutes[Math.min(attempts.length - 1, destination.retryBackoffMinutes.length - 1)] || 0; + return new Date(Date.parse(lastAttempt.attemptedAt) + backoff * 60 * 1000).toISOString(); +} + +function classifyDelivery(manifest, event, delivery) { + const destination = buildDestinationIndex(manifest).get(delivery.destinationId); + const attempts = delivery.attempts || []; + const lastAttempt = attempts.at(-1); + const envelope = buildEnvelope(manifest, event, destination); + const supportedEvent = destination.acceptedEventTypes.includes(event.type); + + if (!supportedEvent) { + return { + state: "manual-review", + reason: "Destination is not subscribed to this event type.", + replayable: false, + envelope, + destination, + attempts + }; + } + + if (attempts.some((attempt) => isSuccess(attempt.statusCode))) { + return { + state: "delivered", + reason: "At least one delivery attempt succeeded.", + replayable: false, + envelope, + destination, + attempts + }; + } + + if (!lastAttempt) { + return { + state: "queued", + reason: "Delivery has not been attempted yet.", + replayable: false, + nextRetryAt: event.occurredAt, + envelope, + destination, + attempts + }; + } + + if (isRetryableStatus(lastAttempt.statusCode) && attempts.length < destination.maxAttempts) { + return { + state: "retryable", + reason: `Last response ${lastAttempt.statusCode} is retryable and attempts remain.`, + replayable: false, + nextRetryAt: nextRetryAt(delivery, destination), + envelope, + destination, + attempts + }; + } + + const approved = Boolean(delivery.approvedForReplayBy && delivery.approvedForReplayAt); + const needsApproval = destination.replayRequiresApproval && !approved; + + return { + state: needsApproval ? "manual-review" : "dead-letter", + reason: needsApproval + ? "Replay requires enterprise admin approval before leaving the dead-letter queue." + : `Delivery exhausted ${attempts.length} attempt(s) with final status ${lastAttempt.statusCode}.`, + replayable: !needsApproval, + envelope, + destination, + attempts, + approval: approved + ? { + approvedBy: delivery.approvedForReplayBy, + approvedAt: delivery.approvedForReplayAt + } + : null + }; +} + +function buildReplayPlan(event, classified) { + return { + eventId: event.id, + eventType: event.type, + destinationId: classified.destination.id, + destinationName: classified.destination.name, + state: classified.state, + replayable: classified.replayable, + idempotencyKey: classified.envelope.idempotencyKey, + bodyDigest: classified.envelope.bodyDigest, + requiredAction: classified.replayable + ? "Replay the signed envelope with the same idempotency key." + : "Collect enterprise approval or destination configuration before replay.", + retentionExpiresAt: new Date( + Date.parse(event.occurredAt) + classified.destination.retentionDays * 24 * 60 * 60 * 1000 + ).toISOString() + }; +} + +function createWebhookLedger(manifest) { + const validationErrors = validateManifest(manifest); + if (validationErrors.length > 0) { + return { + valid: false, + validationErrors, + health: "invalid-manifest", + deliveries: [], + replayPlans: [], + summary: { + totalDeliveries: 0, + delivered: 0, + retryable: 0, + deadLetter: 0, + replayReady: 0, + manualReview: 0 + } + }; + } + + const deliveries = []; + for (const event of manifest.events) { + for (const delivery of event.deliveries) { + const classified = classifyDelivery(manifest, event, delivery); + deliveries.push({ + eventId: event.id, + eventType: event.type, + projectId: event.projectId, + destinationId: delivery.destinationId, + destinationName: classified.destination.name, + state: classified.state, + reason: classified.reason, + replayable: classified.replayable, + nextRetryAt: classified.nextRetryAt || null, + attempts: classified.attempts.length, + idempotencyKey: classified.envelope.idempotencyKey, + envelopeDigest: classified.envelope.bodyDigest, + signaturePreview: classified.envelope.signaturePreview, + approval: classified.approval || null + }); + } + } + + const replayPlans = []; + for (const event of manifest.events) { + for (const delivery of event.deliveries) { + const classified = classifyDelivery(manifest, event, delivery); + if (["dead-letter", "manual-review"].includes(classified.state)) { + replayPlans.push(buildReplayPlan(event, classified)); + } + } + } + + const count = (state) => deliveries.filter((delivery) => delivery.state === state).length; + const manualReview = count("manual-review"); + const deadLetter = count("dead-letter") + manualReview; + const summary = { + organizationId: manifest.organization.id, + totalDeliveries: deliveries.length, + delivered: count("delivered"), + retryable: count("retryable"), + deadLetter, + replayReady: deliveries.filter((delivery) => delivery.state === "dead-letter" && delivery.replayable).length, + manualReview + }; + + const health = + summary.manualReview > 0 || summary.deadLetter > 0 + ? "degraded" + : summary.retryable > 0 + ? "watch" + : "healthy"; + + return { + valid: true, + validationErrors: [], + health, + deliveries, + replayPlans, + summary, + auditDigest: digest({ + summary, + deliveries: deliveries.map((delivery) => ({ + eventId: delivery.eventId, + destinationId: delivery.destinationId, + state: delivery.state, + envelopeDigest: delivery.envelopeDigest + })) + }) + }; +} + +function formatLedgerReport(ledger) { + if (!ledger.valid) { + return [`Manifest invalid:`, ...ledger.validationErrors.map((error) => `- ${error}`)].join("\n"); + } + + const lines = [ + `Enterprise delivery health: ${ledger.health}`, + `Total deliveries: ${ledger.summary.totalDeliveries}`, + `Delivered events: ${ledger.summary.delivered}`, + `Dead-letter events: ${ledger.summary.deadLetter}`, + `Replay-ready events: ${ledger.summary.replayReady}`, + `Manual review events: ${ledger.summary.manualReview}`, + `Audit digest: ${ledger.auditDigest}`, + "" + ]; + + for (const delivery of ledger.deliveries) { + lines.push(`${delivery.state.toUpperCase()} ${delivery.eventId} -> ${delivery.destinationId}`); + lines.push(` reason: ${delivery.reason}`); + lines.push(` idempotency: ${delivery.idempotencyKey}`); + } + + return lines.join("\n"); +} + +module.exports = { + buildEnvelope, + createWebhookLedger, + digest, + formatLedgerReport, + stableStringify, + validateManifest +}; diff --git a/enterprise-webhook-replay-ledger/test/webhook-ledger.test.js b/enterprise-webhook-replay-ledger/test/webhook-ledger.test.js new file mode 100644 index 0000000..663b3bf --- /dev/null +++ b/enterprise-webhook-replay-ledger/test/webhook-ledger.test.js @@ -0,0 +1,92 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const { + buildEnvelope, + createWebhookLedger, + digest, + stableStringify, + validateManifest +} = require("../src/webhook-ledger"); + +const samplePath = path.join(__dirname, "..", "data", "sample-enterprise-events.json"); + +function loadSample() { + return JSON.parse(fs.readFileSync(samplePath, "utf8")); +} + +test("classifies delivered, dead-letter, replay-ready, and manual-review states", () => { + const ledger = createWebhookLedger(loadSample()); + + assert.equal(ledger.health, "degraded"); + assert.equal(ledger.summary.totalDeliveries, 4); + assert.equal(ledger.summary.delivered, 2); + assert.equal(ledger.summary.deadLetter, 2); + assert.equal(ledger.summary.replayReady, 1); + assert.equal(ledger.summary.manualReview, 1); +}); + +test("marks approved Canvas delivery as replayable after failed attempts", () => { + const ledger = createWebhookLedger(loadSample()); + const delivery = ledger.deliveries.find((item) => item.destinationId === "canvas-lms"); + + assert.equal(delivery.state, "dead-letter"); + assert.equal(delivery.replayable, true); + assert.equal(delivery.approval.approvedBy, "enterprise-admin:northbridge-research-office"); + assert.match(delivery.idempotencyKey, /^[a-f0-9]{24}$/); +}); + +test("holds grant portal failures for manual review when replay approval is missing", () => { + const ledger = createWebhookLedger(loadSample()); + const delivery = ledger.deliveries.find((item) => item.destinationId === "grant-portal"); + + assert.equal(delivery.state, "manual-review"); + assert.equal(delivery.replayable, false); + assert.match(delivery.reason, /approval/); +}); + +test("builds stable signed envelopes for idempotent delivery", () => { + const manifest = loadSample(); + const event = manifest.events[0]; + const destination = manifest.destinations[0]; + const first = buildEnvelope(manifest, event, destination); + const second = buildEnvelope(manifest, event, destination); + + assert.deepEqual(first, second); + assert.match(first.idempotencyKey, /^[a-f0-9]{24}$/); + assert.match(first.bodyDigest, /^sha256:[a-f0-9]{64}$/); + assert.match(first.signaturePreview, /^sha256:[a-f0-9]{17}$/); +}); + +test("creates replay plans with retention windows", () => { + const ledger = createWebhookLedger(loadSample()); + + assert.equal(ledger.replayPlans.length, 2); + assert.equal( + ledger.replayPlans.some((plan) => plan.eventId === "evt-review-completed-017" && plan.replayable), + true + ); + assert.equal( + ledger.replayPlans.some((plan) => plan.eventId === "evt-compliance-failed-006" && !plan.replayable), + true + ); +}); + +test("validates destination and event manifest shape", () => { + const manifest = loadSample(); + manifest.destinations[0].endpoint = "http://repo.example.edu/webhooks/scibase"; + + const errors = validateManifest(manifest); + + assert.equal(errors.some((error) => error.includes("https endpoint")), true); +}); + +test("uses deterministic stable digests for audit evidence", () => { + const left = { b: 2, a: { d: 4, c: 3 } }; + const right = { a: { c: 3, d: 4 }, b: 2 }; + + assert.equal(stableStringify(left), stableStringify(right)); + assert.equal(digest(left), digest(right)); + assert.match(digest(left), /^sha256:[a-f0-9]{64}$/); +});