From 832a4d7c77847d391f9dc2898d585af088ad323e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=93=AD=E9=94=8B?= Date: Fri, 15 May 2026 19:42:34 +0800 Subject: [PATCH] Add enterprise webhook replay ledger --- enterprise-webhook-replay-ledger/README.md | 68 ++++ .../data/sample-enterprise-events.json | 188 +++++++++ .../docs/demo.mp4 | Bin 0 -> 53203 bytes .../docs/demo.svg | 55 +++ .../docs/requirement-map.md | 41 ++ enterprise-webhook-replay-ledger/package.json | 15 + .../scripts/demo.js | 17 + .../src/webhook-ledger.js | 357 ++++++++++++++++++ .../test/webhook-ledger.test.js | 92 +++++ 9 files changed, 833 insertions(+) create mode 100644 enterprise-webhook-replay-ledger/README.md create mode 100644 enterprise-webhook-replay-ledger/data/sample-enterprise-events.json create mode 100644 enterprise-webhook-replay-ledger/docs/demo.mp4 create mode 100644 enterprise-webhook-replay-ledger/docs/demo.svg create mode 100644 enterprise-webhook-replay-ledger/docs/requirement-map.md create mode 100644 enterprise-webhook-replay-ledger/package.json create mode 100644 enterprise-webhook-replay-ledger/scripts/demo.js create mode 100644 enterprise-webhook-replay-ledger/src/webhook-ledger.js create mode 100644 enterprise-webhook-replay-ledger/test/webhook-ledger.test.js 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 0000000000000000000000000000000000000000..eca42b284b0719ea36a3da6edfee9a89d91af9ba GIT binary patch literal 53203 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 srr + 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}$/); +});