diff --git a/enterprise-audit-signal-router/README.md b/enterprise-audit-signal-router/README.md new file mode 100644 index 0000000..5a688bf --- /dev/null +++ b/enterprise-audit-signal-router/README.md @@ -0,0 +1,51 @@ +# Enterprise Audit Signal Router + +A dependency-free Enterprise Tooling milestone for SCIBASE issue #19. It gives institutional admins a deterministic way to turn project activity into dashboard metrics, signed integration events, compliance alerts, and export packets for repositories, journals, and funders. + +## What is included + +- `src/index.js` — core library for workspace normalization, dashboard aggregation, compliance checks, REST/API catalog generation, signed webhook routing, export packet creation, and an end-to-end enterprise brief. +- `src/cli.js` — CLI demo that reads the sample fixture and prints an audit summary. +- `sample/enterprise-fixture.json` — representative university/corporate R&D workspace with public/private projects, contributors, API clients, mandates, DOI/ORCID/citation metadata, and events. +- `test/enterprise-audit-signal-router.test.js` — Node test coverage for every issue capability. +- `docs/demo.svg` and `docs/demo.mp4` — short visual demo artifacts. + +## Requirement mapping + +| Issue #19 capability | Implementation evidence | +| --- | --- | +| Admin dashboards for organization-wide project oversight | `buildAdminDashboard()` reports total/public/private projects, projects by department/lab, custom tags, and dashboard hash. | +| Contributor analytics, activity heatmaps, top researchers, and cross-lab collaborations | `contributorAnalytics` ranks researchers, computes activity heat, and records cross-lab collaboration counts. | +| Usage stats for logins, submissions, storage, and compute | `usageStats` aggregates logins, submissions, storage GB, and compute hours across the workspace. | +| Productivity metrics for lab output, AI reviews, and peer reviews | `productivityMetrics` tracks projects per lab, AI reviews generated, and peer reviews completed. | +| Compliance tracking for funder mandates, open access, and reproducibility scores | `evaluateCompliance()` flags missing funder/open-access/reproducibility evidence and emits compliance labels. | +| Secure RESTful API integration with repositories, LMS, ELNs, HRIS, and ORCID | `buildApiCatalog()` defines scoped REST endpoints, integration readiness for DSpace/Invenio/Canvas/Moodle/ELN/HRIS/ORCID, and security controls. | +| Webhooks for project creation, publication, and review events | `routeEnterpriseEvents()` creates HMAC-signed deliveries with deterministic payloads and destination routing. | +| Export pipelines for preprints, indexed repositories, journals, and funders | `buildExportPacket()` prepares arXiv, bioRxiv, Zenodo, PubMed Central, NIH RePORTER, UKRI, or custom packets. | +| Formatting plugins with DOI, ORCID, citation, and version preservation | Export packets include manuscript formats, DOI, ORCID list, citation text, tags, funder mandates, version history, files, and a packet hash. | + +## Run verification + +```bash +cd enterprise-audit-signal-router +npm run check +``` + +Expected result: six Node tests pass, then the CLI demo prints the institution, project visibility counts, top researcher, compliance risk count, webhook delivery count, export packet count, and audit hash. + +## Demo output + +```text +Enterprise Audit Signal Router Demo +Institution: Midlands Research Office +Projects: 2 (1 public / 1 private) +Top researcher: Ada Kim +Compliance risks: 1 +Webhook deliveries: 16 +Export packets: 2 +Audit hash: +``` + +## AI-assisted disclosure + +This contribution was produced with AI assistance and manually reviewed/verified before submission. diff --git a/enterprise-audit-signal-router/docs/demo.mp4 b/enterprise-audit-signal-router/docs/demo.mp4 new file mode 100644 index 0000000..26f2e5d Binary files /dev/null and b/enterprise-audit-signal-router/docs/demo.mp4 differ diff --git a/enterprise-audit-signal-router/docs/demo.svg b/enterprise-audit-signal-router/docs/demo.svg new file mode 100644 index 0000000..8e39ff3 --- /dev/null +++ b/enterprise-audit-signal-router/docs/demo.svg @@ -0,0 +1,33 @@ + + Enterprise Audit Signal Router Demo + Dashboard, signed webhooks, and export packets for SCIBASE enterprise tooling. + + Enterprise Audit Signal Router + Admin dashboards • API/webhooks • export pipelines + + + Admin Dashboard + 2 projects + 50 logins + 19 AI reviews + 1 compliance risk + + + + Signed Webhooks + project.created + publication.released + review.completed + HMAC + scoped APIs + + + + Export Packets + arXiv / Zenodo + PubMed / NIH + DOI + ORCID + version history + + + Requirement map: visibility, analytics, usage, productivity, compliance, integrations, webhooks, repository/journal/funder exports. + diff --git a/enterprise-audit-signal-router/package.json b/enterprise-audit-signal-router/package.json new file mode 100644 index 0000000..ec0cc24 --- /dev/null +++ b/enterprise-audit-signal-router/package.json @@ -0,0 +1,12 @@ +{ + "name": "enterprise-audit-signal-router", + "version": "0.1.0", + "description": "Dependency-free enterprise tooling module for admin dashboards, secure APIs/webhooks, and research export pipelines.", + "type": "module", + "private": true, + "scripts": { + "check": "npm test && npm run demo", + "demo": "node src/cli.js", + "test": "node --test test/*.test.js" + } +} diff --git a/enterprise-audit-signal-router/sample/enterprise-fixture.json b/enterprise-audit-signal-router/sample/enterprise-fixture.json new file mode 100644 index 0000000..29ab69f --- /dev/null +++ b/enterprise-audit-signal-router/sample/enterprise-fixture.json @@ -0,0 +1,63 @@ +{ + "institution": { + "id": "midlands-research-office", + "name": "Midlands Research Office", + "departments": ["Bioengineering", "Climate Systems", "Materials Lab"], + "apiClients": [ + {"id": "dspace-sync", "system": "DSpace", "scopes": ["projects:read", "exports:write"]}, + {"id": "canvas-lms", "system": "Canvas", "scopes": ["projects:read", "reviews:read"]}, + {"id": "orcid-hris", "system": "ORCID-HRIS", "scopes": ["contributors:sync"]} + ], + "webhookSecret": "institutional-secret" + }, + "contributors": [ + {"id": "r-ada", "name": "Ada Kim", "department": "Bioengineering", "orcid": "0000-0002-1111-2222", "lab": "Bio-AI", "logins": 22}, + {"id": "r-linus", "name": "Linus Park", "department": "Climate Systems", "orcid": "0000-0003-3333-4444", "lab": "Climate Risk", "logins": 17}, + {"id": "r-marie", "name": "Marie Choi", "department": "Materials Lab", "orcid": "0000-0001-5555-6666", "lab": "NanoLab", "logins": 11} + ], + "projects": [ + { + "id": "proj-open-catalyst", + "title": "Open Catalyst Reproducibility", + "visibility": "public", + "department": "Materials Lab", + "lab": "NanoLab", + "tags": ["GRANT-TRACKED", "DOCTORAL WORK"], + "contributors": ["r-marie", "r-ada"], + "storageGb": 184, + "computeHours": 96, + "submissions": 4, + "aiReviews": 8, + "peerReviews": 6, + "funderMandates": ["NIH data sharing"], + "openAccess": true, + "reproducibilityScore": 0.91, + "doi": "10.5555/scibase.catalyst.2026", + "citations": ["Kim A, Choi M. Open catalyst reproducibility. 2026."], + "versions": ["v0.1 protocol", "v1.0 preprint"], + "events": ["project.created", "review.completed", "publication.released"] + }, + { + "id": "proj-private-climate", + "title": "Private Climate Model Review", + "visibility": "private", + "department": "Climate Systems", + "lab": "Climate Risk", + "tags": ["BUSINESS-UNIT-RD"], + "contributors": ["r-linus"], + "storageGb": 640, + "computeHours": 420, + "submissions": 2, + "aiReviews": 11, + "peerReviews": 1, + "funderMandates": ["Horizon EU open data"], + "openAccess": false, + "reproducibilityScore": 0.68, + "doi": null, + "citations": ["Park L. Private climate model review. 2026."], + "versions": ["internal-draft"], + "events": ["project.created", "review.completed"] + } + ], + "usageWindow": "2026-05" +} diff --git a/enterprise-audit-signal-router/src/cli.js b/enterprise-audit-signal-router/src/cli.js new file mode 100644 index 0000000..7c1b419 --- /dev/null +++ b/enterprise-audit-signal-router/src/cli.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildEnterpriseToolingBrief } from './index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturePath = path.join(__dirname, '..', 'sample', 'enterprise-fixture.json'); +const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); +const brief = buildEnterpriseToolingBrief(fixture); + +console.log('Enterprise Audit Signal Router Demo'); +console.log(`Institution: ${brief.institution}`); +console.log(`Projects: ${brief.dashboard.projectCounts.total} (${brief.dashboard.projectCounts.public} public / ${brief.dashboard.projectCounts.private} private)`); +console.log(`Top researcher: ${brief.dashboard.contributorAnalytics.topResearchers[0].name}`); +console.log(`Compliance risks: ${brief.dashboard.complianceTracking.filter((item) => item.status === 'attention-required').length}`); +console.log(`Webhook deliveries: ${brief.routedEvents.reduce((sum, route) => sum + route.deliveries.length, 0)}`); +console.log(`Export packets: ${brief.exportPackets.length}`); +console.log(`Audit hash: ${brief.auditHash.slice(0, 16)}`); diff --git a/enterprise-audit-signal-router/src/index.js b/enterprise-audit-signal-router/src/index.js new file mode 100644 index 0000000..cb83e5b --- /dev/null +++ b/enterprise-audit-signal-router/src/index.js @@ -0,0 +1,287 @@ +import crypto from 'node:crypto'; + +const DEFAULT_EVENT_ROUTES = { + 'project.created': ['DSpace', 'Canvas', 'HRIS'], + 'publication.released': ['DSpace', 'Zenodo', 'PubMed Central', 'funder-portal'], + 'review.completed': ['Canvas', 'ELN', 'reporting-platform'] +}; + +const EXPORT_TARGETS = { + arxiv: { format: 'LaTeX', mandate: 'preprint-ready manuscript' }, + biorxiv: { format: 'JATS', mandate: 'life-science preprint deposit' }, + zenodo: { format: 'DataCite JSON-LD', mandate: 'dataset and software DOI deposit' }, + 'pubmed-central': { format: 'JATS XML', mandate: 'public-access manuscript archive' }, + 'nih-reporter': { format: 'grant report packet', mandate: 'NIH progress reporting' }, + ukri: { format: 'funder compliance CSV', mandate: 'UKRI open research reporting' } +}; + +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 digest(value) { + return crypto.createHash('sha256').update(stableStringify(value)).digest('hex'); +} + +function signPayload(payload, secret) { + return crypto.createHmac('sha256', secret || 'scibase-enterprise').update(stableStringify(payload)).digest('hex'); +} + +function indexById(items = []) { + return new Map(items.map((item) => [item.id, item])); +} + +export function normalizeEnterpriseWorkspace(input) { + const institution = input.institution || {}; + const contributors = (input.contributors || []).map((contributor) => ({ + logins: 0, + ...contributor, + department: contributor.department || 'Unassigned', + lab: contributor.lab || 'Unassigned' + })); + const knownContributors = indexById(contributors); + const projects = (input.projects || []).map((project) => ({ + visibility: 'private', + tags: [], + contributors: [], + storageGb: 0, + computeHours: 0, + submissions: 0, + aiReviews: 0, + peerReviews: 0, + funderMandates: [], + openAccess: false, + reproducibilityScore: 0, + citations: [], + versions: [], + events: [], + ...project, + contributorDetails: (project.contributors || []).map((id) => knownContributors.get(id)).filter(Boolean) + })); + + return { + institution: { + id: institution.id || 'enterprise-institution', + name: institution.name || 'Enterprise Institution', + departments: institution.departments || [], + apiClients: institution.apiClients || [], + webhookSecret: institution.webhookSecret || 'scibase-enterprise' + }, + contributors, + projects, + usageWindow: input.usageWindow || 'current' + }; +} + +export function evaluateCompliance(project) { + const missing = []; + if ((project.funderMandates || []).length === 0) missing.push('funder mandate mapping'); + if (!project.openAccess) missing.push('open access confirmation'); + if ((project.reproducibilityScore || 0) < 0.8) missing.push('reproducibility score >= 0.80'); + if (!project.doi && project.visibility === 'public') missing.push('DOI for public output'); + + return { + projectId: project.id, + openAccess: Boolean(project.openAccess), + funderMandates: project.funderMandates || [], + reproducibilityScore: Number(project.reproducibilityScore || 0), + status: missing.length === 0 ? 'compliant' : 'attention-required', + missing, + flags: missing.map((item) => `COMPLIANCE:${item.toUpperCase()}`) + }; +} + +export function buildAdminDashboard(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const projectCounts = workspace.projects.reduce((acc, project) => { + acc.total += 1; + acc[project.visibility] = (acc[project.visibility] || 0) + 1; + acc.byDepartment[project.department] = (acc.byDepartment[project.department] || 0) + 1; + for (const tag of project.tags) acc.tags[tag] = (acc.tags[tag] || 0) + 1; + return acc; + }, { total: 0, public: 0, private: 0, byDepartment: {}, tags: {} }); + + const contributorStats = workspace.contributors.map((contributor) => { + const projects = workspace.projects.filter((project) => project.contributors.includes(contributor.id)); + const labs = new Set(projects.map((project) => project.lab)); + return { + id: contributor.id, + name: contributor.name, + department: contributor.department, + activityHeat: contributor.logins + projects.reduce((sum, project) => sum + project.submissions + project.aiReviews + project.peerReviews, 0), + projectCount: projects.length, + crossLabCollaborations: Math.max(0, labs.size - 1), + orcid: contributor.orcid + }; + }).sort((a, b) => b.activityHeat - a.activityHeat || a.name.localeCompare(b.name)); + + const usage = workspace.projects.reduce((acc, project) => { + acc.logins = workspace.contributors.reduce((sum, contributor) => sum + contributor.logins, 0); + acc.submissions += project.submissions; + acc.storageGb += project.storageGb; + acc.computeHours += project.computeHours; + return acc; + }, { logins: 0, submissions: 0, storageGb: 0, computeHours: 0 }); + + const productivity = workspace.projects.reduce((acc, project) => { + acc.projectsPerLab[project.lab] = (acc.projectsPerLab[project.lab] || 0) + 1; + acc.aiReviewsGenerated += project.aiReviews; + acc.peerReviewsCompleted += project.peerReviews; + return acc; + }, { projectsPerLab: {}, aiReviewsGenerated: 0, peerReviewsCompleted: 0 }); + + const compliance = workspace.projects.map(evaluateCompliance); + + return { + institution: workspace.institution.name, + usageWindow: workspace.usageWindow, + projectCounts, + contributorAnalytics: { + topResearchers: contributorStats.slice(0, 5), + activityHeatmap: contributorStats.map(({ id, activityHeat }) => ({ contributorId: id, heat: activityHeat })), + crossLabCollaborations: contributorStats.filter((stat) => stat.crossLabCollaborations > 0) + }, + usageStats: usage, + productivityMetrics: productivity, + complianceTracking: compliance, + customTags: projectCounts.tags, + dashboardHash: digest({ projectCounts, contributorStats, usage, productivity, compliance }) + }; +} + +export function buildApiCatalog(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const clientSystems = new Set(workspace.institution.apiClients.map((client) => client.system)); + const integrations = ['DSpace', 'Invenio', 'Canvas', 'Moodle', 'ELN', 'lab-inventory', 'HRIS', 'ORCID'].map((system) => ({ + system, + configured: clientSystems.has(system) || [...clientSystems].some((client) => client.toLowerCase().includes(system.toLowerCase())), + auth: 'signed service token with scoped API key rotation' + })); + + return { + restEndpoints: [ + { method: 'GET', path: '/api/enterprise/projects', scope: 'projects:read', purpose: 'list public/private hosted projects for dashboards and archives' }, + { method: 'GET', path: '/api/enterprise/contributors', scope: 'contributors:sync', purpose: 'sync ORCID/HRIS personnel metadata' }, + { method: 'POST', path: '/api/enterprise/exports', scope: 'exports:write', purpose: 'create export packets for repositories, journals, and funders' }, + { method: 'GET', path: '/api/enterprise/reviews', scope: 'reviews:read', purpose: 'share reproducibility scores and peer-review status with LMS/ELN systems' } + ], + webhookEvents: Object.keys(DEFAULT_EVENT_ROUTES).map((event) => ({ event, destinations: DEFAULT_EVENT_ROUTES[event], signatureHeader: 'X-SCIBASE-Signature-256' })), + integrations, + apiClients: workspace.institution.apiClients, + securityControls: ['HMAC webhook signatures', 'least-privilege scopes', 'idempotency keys', 'PII-minimized payloads', 'audit hashes'] + }; +} + +export function routeEnterpriseEvents(input, events = []) { + const workspace = normalizeEnterpriseWorkspace(input); + const projects = indexById(workspace.projects); + const secret = workspace.institution.webhookSecret; + + return events.map((event) => { + const project = projects.get(event.projectId); + const payload = { + event: event.type, + projectId: event.projectId, + title: project?.title || 'Unknown project', + visibility: project?.visibility || 'unknown', + department: project?.department || 'unknown', + reproducibilityScore: project?.reproducibilityScore ?? null, + doi: project?.doi || null, + occurredAt: event.occurredAt || '2026-05-15T00:00:00.000Z' + }; + const destinations = DEFAULT_EVENT_ROUTES[event.type] || ['reporting-platform']; + return { + eventId: event.id || digest(payload).slice(0, 12), + type: event.type, + destinations, + deliveries: destinations.map((destination) => ({ + destination, + method: 'POST', + url: `https://integrations.example/${destination.toLowerCase().replace(/\s+/g, '-')}/webhooks/scibase`, + payload, + headers: { + 'Content-Type': 'application/json', + 'X-SCIBASE-Event': event.type, + 'X-SCIBASE-Signature-256': signPayload({ destination, payload }, secret) + } + })) + }; + }); +} + +export function buildExportPacket(input, projectId, requestedTargets = ['zenodo', 'pubmed-central', 'nih-reporter']) { + const workspace = normalizeEnterpriseWorkspace(input); + const project = workspace.projects.find((item) => item.id === projectId); + if (!project) throw new Error(`Unknown project: ${projectId}`); + const contributorLookup = indexById(workspace.contributors); + const contributors = project.contributors.map((id) => contributorLookup.get(id)).filter(Boolean).map((contributor) => ({ + name: contributor.name, + orcid: contributor.orcid, + department: contributor.department + })); + const targets = requestedTargets.map((target) => ({ + target, + ...(EXPORT_TARGETS[target] || { format: 'custom package', mandate: 'custom institutional export' }) + })); + + return { + projectId: project.id, + title: project.title, + visibility: project.visibility, + targets, + manuscriptFormats: [...new Set(targets.map((target) => target.format))], + metadata: { + doi: project.doi, + orcids: contributors.map((contributor) => contributor.orcid).filter(Boolean), + citations: project.citations, + versionHistory: project.versions, + tags: project.tags, + funderMandates: project.funderMandates + }, + compliance: evaluateCompliance(project), + files: targets.map((target) => `${project.id}/${target.target}.${target.format.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`), + packetHash: digest({ project, contributors, targets }) + }; +} + +export function buildEnterpriseToolingBrief(input) { + const workspace = normalizeEnterpriseWorkspace(input); + const dashboard = buildAdminDashboard(workspace); + const apiCatalog = buildApiCatalog(workspace); + const events = workspace.projects.flatMap((project) => project.events.map((type, index) => ({ + id: `${project.id}-${type}-${index}`, + type, + projectId: project.id, + occurredAt: `2026-05-${String(10 + index).padStart(2, '0')}T12:00:00.000Z` + }))); + const routedEvents = routeEnterpriseEvents(workspace, events); + const exportPackets = workspace.projects.map((project) => buildExportPacket(workspace, project.id)); + + const requirementMap = { + adminDashboards: ['project visibility counts', 'contributor heatmaps', 'usage stats', 'productivity metrics', 'compliance tracking', 'custom tags'], + apiAndWebhooks: ['secure REST endpoints', 'DSpace/Invenio/LMS/ELN/HRIS/ORCID integration catalog', 'signed project/publication/review webhooks'], + exportPipelines: ['preprint and repository targets', 'journal/funder formats', 'DOI/ORCID/citation/version preservation'] + }; + + return { + institution: workspace.institution.name, + dashboard, + apiCatalog, + routedEvents, + exportPackets, + requirementMap, + acceptanceEvidence: [ + `${dashboard.projectCounts.total} projects summarized across public/private visibility`, + `${dashboard.complianceTracking.filter((item) => item.status === 'attention-required').length} compliance risks flagged`, + `${routedEvents.reduce((sum, route) => sum + route.deliveries.length, 0)} signed webhook deliveries prepared`, + `${exportPackets.length} export packets generated with preserved DOI/ORCID/citation metadata` + ], + auditHash: digest({ dashboard, apiCatalog, routedEvents, exportPackets, requirementMap }) + }; +} diff --git a/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js b/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js new file mode 100644 index 0000000..28902b6 --- /dev/null +++ b/enterprise-audit-signal-router/test/enterprise-audit-signal-router.test.js @@ -0,0 +1,96 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildAdminDashboard, + buildApiCatalog, + buildEnterpriseToolingBrief, + buildExportPacket, + evaluateCompliance, + routeEnterpriseEvents +} from '../src/index.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'sample', 'enterprise-fixture.json'), 'utf8')); + +test('admin dashboard covers visibility, contributor analytics, usage, productivity, compliance, and custom tags', () => { + const dashboard = buildAdminDashboard(fixture); + + assert.equal(dashboard.projectCounts.total, 2); + assert.equal(dashboard.projectCounts.public, 1); + assert.equal(dashboard.projectCounts.private, 1); + assert.equal(dashboard.customTags['GRANT-TRACKED'], 1); + assert.equal(dashboard.usageStats.logins, 50); + assert.equal(dashboard.usageStats.submissions, 6); + assert.equal(dashboard.productivityMetrics.aiReviewsGenerated, 19); + assert.equal(dashboard.productivityMetrics.peerReviewsCompleted, 7); + assert.equal(dashboard.contributorAnalytics.topResearchers[0].name, 'Ada Kim'); + assert.equal(dashboard.complianceTracking.some((entry) => entry.status === 'attention-required'), true); + assert.match(dashboard.dashboardHash, /^[a-f0-9]{64}$/); +}); + +test('compliance evaluator flags funder, open-access, and reproducibility gaps', () => { + const privateProject = fixture.projects.find((project) => project.id === 'proj-private-climate'); + const compliance = evaluateCompliance(privateProject); + + assert.equal(compliance.status, 'attention-required'); + assert.ok(compliance.missing.includes('open access confirmation')); + assert.ok(compliance.missing.includes('reproducibility score >= 0.80')); + assert.ok(compliance.flags.every((flag) => flag.startsWith('COMPLIANCE:'))); +}); + +test('API catalog includes scoped REST endpoints, webhooks, integrations, and security controls', () => { + const catalog = buildApiCatalog(fixture); + + assert.ok(catalog.restEndpoints.find((endpoint) => endpoint.path === '/api/enterprise/projects')); + assert.ok(catalog.webhookEvents.find((event) => event.event === 'publication.released')); + assert.ok(catalog.integrations.find((integration) => integration.system === 'DSpace' && integration.configured)); + assert.ok(catalog.integrations.find((integration) => integration.system === 'Canvas' && integration.configured)); + assert.ok(catalog.integrations.find((integration) => integration.system === 'ORCID' && integration.configured)); + assert.ok(catalog.securityControls.includes('HMAC webhook signatures')); + assert.ok(catalog.securityControls.includes('least-privilege scopes')); +}); + +test('event router prepares signed webhook deliveries for project, publication, and review events', () => { + const routes = routeEnterpriseEvents(fixture, [ + { type: 'project.created', projectId: 'proj-open-catalyst', occurredAt: '2026-05-10T12:00:00.000Z' }, + { type: 'publication.released', projectId: 'proj-open-catalyst', occurredAt: '2026-05-11T12:00:00.000Z' }, + { type: 'review.completed', projectId: 'proj-private-climate', occurredAt: '2026-05-12T12:00:00.000Z' } + ]); + + assert.equal(routes.length, 3); + assert.deepEqual(routes[0].destinations, ['DSpace', 'Canvas', 'HRIS']); + assert.ok(routes[1].deliveries.find((delivery) => delivery.destination === 'Zenodo')); + assert.equal(routes[2].deliveries[0].headers['X-SCIBASE-Event'], 'review.completed'); + assert.match(routes[2].deliveries[0].headers['X-SCIBASE-Signature-256'], /^[a-f0-9]{64}$/); +}); + +test('export packet preserves DOI, ORCID, citation, version history, journal, repository, and funder targets', () => { + const packet = buildExportPacket(fixture, 'proj-open-catalyst', ['arxiv', 'zenodo', 'nih-reporter']); + + assert.equal(packet.metadata.doi, '10.5555/scibase.catalyst.2026'); + assert.ok(packet.metadata.orcids.includes('0000-0002-1111-2222')); + assert.ok(packet.metadata.citations[0].includes('Open catalyst')); + assert.ok(packet.metadata.versionHistory.includes('v1.0 preprint')); + assert.ok(packet.targets.find((target) => target.target === 'arxiv' && target.format === 'LaTeX')); + assert.ok(packet.targets.find((target) => target.target === 'zenodo' && target.format === 'DataCite JSON-LD')); + assert.ok(packet.targets.find((target) => target.target === 'nih-reporter' && target.format === 'grant report packet')); + assert.equal(packet.compliance.status, 'compliant'); + assert.match(packet.packetHash, /^[a-f0-9]{64}$/); +}); + +test('enterprise brief maps every issue capability to concrete dashboard, integration, webhook, and export evidence', () => { + const brief = buildEnterpriseToolingBrief(fixture); + + assert.equal(brief.requirementMap.adminDashboards.length, 6); + assert.equal(brief.requirementMap.apiAndWebhooks.length, 3); + assert.equal(brief.requirementMap.exportPipelines.length, 3); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('projects summarized'))); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('signed webhook deliveries'))); + assert.ok(brief.acceptanceEvidence.some((item) => item.includes('export packets generated'))); + assert.ok(brief.routedEvents.length >= 5); + assert.equal(brief.exportPackets.length, 2); + assert.match(brief.auditHash, /^[a-f0-9]{64}$/); +});