diff --git a/enterprise-token-rotation-guard/package.json b/enterprise-token-rotation-guard/package.json
new file mode 100644
index 00000000..d71d33bf
--- /dev/null
+++ b/enterprise-token-rotation-guard/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "scibase-enterprise-token-rotation-guard",
+ "version": "1.0.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "test": "node --test test/*.test.js",
+ "demo": "node scripts/demo.js"
+ }
+}
diff --git a/enterprise-token-rotation-guard/readme.md b/enterprise-token-rotation-guard/readme.md
new file mode 100644
index 00000000..4b1d6520
--- /dev/null
+++ b/enterprise-token-rotation-guard/readme.md
@@ -0,0 +1,22 @@
+# Enterprise Token Rotation Guard
+
+This module contributes to SCIBASE issue #19, Enterprise Tooling.
+
+It evaluates institutional API integrations before admins keep them active. The guard checks token rotation age, least-privilege scopes, active owner assignment, audit export recency, SSO group drift, and restricted-data approvals.
+
+## Local Verification
+
+```bash
+npm test
+npm run demo
+```
+
+## Demo Evidence
+
+The demo transcript is captured in `reports/demo-transcript.md`. The demo generates these reviewer artifacts:
+
+- `reports/enterprise-token-report.md`
+- `reports/enterprise-token-packet.json`
+- `reports/summary.svg`
+
+The demo data is synthetic and does not contact SSO providers, institutional repositories, or production APIs.
diff --git a/enterprise-token-rotation-guard/reports/demo-transcript.md b/enterprise-token-rotation-guard/reports/demo-transcript.md
new file mode 100644
index 00000000..4f75e7f6
--- /dev/null
+++ b/enterprise-token-rotation-guard/reports/demo-transcript.md
@@ -0,0 +1,35 @@
+# Enterprise Token Rotation Guard Demo Transcript
+
+Date verified: 2026-06-15
+
+Commands run from `enterprise-token-rotation-guard`:
+
+```bash
+npm test
+npm run demo
+```
+
+Test result:
+
+```text
+5 tests passed
+0 tests failed
+```
+
+Demo output:
+
+```json
+{
+ "status": "hold_integration",
+ "digest": "e8c0d01bc2dfd3d313262b963fc9babcd8ab9588f6ccba24526047ed6453fb6b",
+ "blockers": 6,
+ "heldIntegrations": 1,
+ "reportsDir": "enterprise-token-rotation-guard/reports"
+}
+```
+
+Generated reviewer artifacts:
+
+- `reports/enterprise-token-report.md`
+- `reports/enterprise-token-packet.json`
+- `reports/summary.svg`
diff --git a/enterprise-token-rotation-guard/reports/enterprise-token-packet.json b/enterprise-token-rotation-guard/reports/enterprise-token-packet.json
new file mode 100644
index 00000000..85132103
--- /dev/null
+++ b/enterprise-token-rotation-guard/reports/enterprise-token-packet.json
@@ -0,0 +1,113 @@
+{
+ "title": "SCIBASE Enterprise Token Rotation Guard",
+ "issue": "SCIBASE.AI#19",
+ "claim": "/claim #19",
+ "evaluation": {
+ "status": "hold_integration",
+ "generatedAt": "2026-06-13T19:00:00.000Z",
+ "packetId": "scibase-enterprise-token-demo",
+ "digest": "e8c0d01bc2dfd3d313262b963fc9babcd8ab9588f6ccba24526047ed6453fb6b",
+ "counts": {
+ "integrations": 2,
+ "owners": 2,
+ "blockers": 6,
+ "warnings": 1,
+ "heldIntegrations": 1
+ },
+ "blockers": [
+ {
+ "code": "orphan_owner",
+ "integrationId": "int-hris-sync",
+ "message": "Integration owner is missing or inactive."
+ },
+ {
+ "code": "stale_token",
+ "integrationId": "int-hris-sync",
+ "message": "Integration token exceeds the rotation window.",
+ "tokenAgeDays": 163
+ },
+ {
+ "code": "overbroad_scopes",
+ "integrationId": "int-hris-sync",
+ "message": "Integration has scopes outside the allowlist.",
+ "overbroadScopes": [
+ "admin:root"
+ ]
+ },
+ {
+ "code": "stale_audit_export",
+ "integrationId": "int-hris-sync",
+ "message": "Audit export is outside the recency window.",
+ "auditAgeDays": 24
+ },
+ {
+ "code": "sso_group_drift",
+ "integrationId": "int-hris-sync",
+ "message": "SSO group drift exceeds policy.",
+ "ssoGroupDrift": 6
+ },
+ {
+ "code": "missing_dpa_approval",
+ "integrationId": "int-hris-sync",
+ "message": "Restricted-data integration lacks DPA approval."
+ }
+ ],
+ "warnings": [
+ {
+ "code": "webhook_failure_streak",
+ "integrationId": "int-hris-sync",
+ "message": "Webhook delivery has repeated failures.",
+ "webhookFailures": 4
+ }
+ ],
+ "decisions": [
+ {
+ "integrationId": "int-dspace-sync",
+ "tenant": "North Campus",
+ "decision": "keep_active",
+ "reasons": [],
+ "requiredActions": []
+ },
+ {
+ "integrationId": "int-hris-sync",
+ "tenant": "Medical Research Institute",
+ "decision": "hold",
+ "reasons": [
+ "orphan_owner",
+ "stale_token",
+ "overbroad_scopes",
+ "stale_audit_export",
+ "sso_group_drift",
+ "missing_dpa_approval"
+ ],
+ "requiredActions": [
+ "Assign an active institutional owner before keeping the integration active.",
+ "Rotate the integration token and record the rotation timestamp.",
+ "Remove scopes outside the enterprise allowlist.",
+ "Generate a fresh audit export before the next sync.",
+ "Reconcile SSO group membership before syncing permissions.",
+ "Attach DPA approval before syncing restricted enterprise data."
+ ]
+ }
+ ],
+ "policy": {
+ "maxTokenAgeDays": 90,
+ "maxAuditExportAgeDays": 7,
+ "maxSsoGroupDrift": 3,
+ "allowedScopes": [
+ "project:read",
+ "project:write",
+ "review:read",
+ "webhook:publish",
+ "audit:read"
+ ]
+ }
+ },
+ "reviewerChecklist": [
+ "Integration tokens are inside the rotation window.",
+ "Scopes are limited to the enterprise allowlist.",
+ "Every integration has an active institutional owner.",
+ "Audit exports are fresh before enterprise syncs.",
+ "Restricted data integrations have DPA approval."
+ ]
+}
diff --git a/enterprise-token-rotation-guard/reports/enterprise-token-report.md b/enterprise-token-rotation-guard/reports/enterprise-token-report.md
new file mode 100644
index 00000000..2abc32fc
--- /dev/null
+++ b/enterprise-token-rotation-guard/reports/enterprise-token-report.md
@@ -0,0 +1,25 @@
+# Enterprise Token Rotation Guard Report
+
+Issue: SCIBASE.AI#19
+Claim marker: `/claim #19`
+Status: `hold_integration`
+Digest: `e8c0d01bc2dfd3d313262b963fc9babcd8ab9588f6ccba24526047ed6453fb6b`
+
+## Reviewer Checklist
+- Integration tokens are inside the rotation window.
+- Scopes are limited to the enterprise allowlist.
+- Every integration has an active institutional owner.
+- Audit exports are fresh before enterprise syncs.
+- Restricted data integrations have DPA approval.
+
+## Blockers
+- orphan_owner: Integration owner is missing or inactive.
+- stale_token: Integration token exceeds the rotation window.
+- overbroad_scopes: Integration has scopes outside the allowlist.
+- stale_audit_export: Audit export is outside the recency window.
+- sso_group_drift: SSO group drift exceeds policy.
+- missing_dpa_approval: Restricted-data integration lacks DPA approval.
+
+## Integration Decisions
+- int-dspace-sync: keep_active
+- int-hris-sync: hold (orphan_owner, stale_token, overbroad_scopes, stale_audit_export, sso_group_drift, missing_dpa_approval)
diff --git a/enterprise-token-rotation-guard/reports/summary.svg b/enterprise-token-rotation-guard/reports/summary.svg
new file mode 100644
index 00000000..14693cba
--- /dev/null
+++ b/enterprise-token-rotation-guard/reports/summary.svg
@@ -0,0 +1 @@
+
diff --git a/enterprise-token-rotation-guard/scripts/demo.js b/enterprise-token-rotation-guard/scripts/demo.js
new file mode 100644
index 00000000..17e96d3b
--- /dev/null
+++ b/enterprise-token-rotation-guard/scripts/demo.js
@@ -0,0 +1,29 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const moduleRoot = path.resolve(__dirname, "..");
+const reportsDir = path.join(moduleRoot, "reports");
+const packet = demoPacket();
+const reviewerPacket = buildReviewerPacket(packet, { now: packet.generatedAt });
+
+fs.mkdirSync(reportsDir, { recursive: true });
+fs.writeFileSync(path.join(reportsDir, "enterprise-token-packet.json"), `${JSON.stringify(reviewerPacket, null, 2)}\n`);
+fs.writeFileSync(path.join(reportsDir, "enterprise-token-report.md"), renderMarkdownReport(packet, { now: packet.generatedAt }));
+fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(packet, { now: packet.generatedAt }));
+
+console.log(JSON.stringify({
+ status: reviewerPacket.evaluation.status,
+ digest: reviewerPacket.evaluation.digest,
+ blockers: reviewerPacket.evaluation.counts.blockers,
+ heldIntegrations: reviewerPacket.evaluation.counts.heldIntegrations,
+ reportsDir
+}, null, 2));
diff --git a/enterprise-token-rotation-guard/src/index.js b/enterprise-token-rotation-guard/src/index.js
new file mode 100644
index 00000000..57496fc4
--- /dev/null
+++ b/enterprise-token-rotation-guard/src/index.js
@@ -0,0 +1,180 @@
+import crypto from "node:crypto";
+
+const DEFAULT_POLICY = {
+ maxTokenAgeDays: 90,
+ maxAuditExportAgeDays: 7,
+ maxSsoGroupDrift: 3,
+ allowedScopes: ["project:read", "project:write", "review:read", "webhook:publish", "audit:read"]
+};
+
+export function evaluateEnterpriseIntegrations(packet, options = {}) {
+ const normalized = normalizePacket(packet);
+ const policy = { ...DEFAULT_POLICY, ...normalized.policy, ...(options.policy ?? {}) };
+ const now = new Date(options.now ?? normalized.generatedAt);
+ const activeOwners = new Set(normalized.owners.filter((owner) => owner.active).map((owner) => owner.id));
+ const blockers = [];
+ const warnings = [];
+ const decisions = [];
+
+ for (const integration of normalized.integrations) {
+ const decision = {
+ integrationId: integration.id,
+ tenant: integration.tenant,
+ decision: "keep_active",
+ reasons: [],
+ requiredActions: []
+ };
+ const tokenAgeDays = daysBetween(new Date(integration.tokenIssuedAt), now);
+ const auditAgeDays = daysBetween(new Date(integration.lastAuditExportAt), now);
+ const overbroadScopes = integration.scopes.filter((scope) => !policy.allowedScopes.includes(scope));
+
+ if (!activeOwners.has(integration.ownerId)) {
+ hold(decision, "orphan_owner", "Assign an active institutional owner before keeping the integration active.");
+ blockers.push(finding("orphan_owner", integration.id, "Integration owner is missing or inactive."));
+ }
+
+ if (tokenAgeDays > policy.maxTokenAgeDays) {
+ hold(decision, "stale_token", "Rotate the integration token and record the rotation timestamp.");
+ blockers.push({ ...finding("stale_token", integration.id, "Integration token exceeds the rotation window."), tokenAgeDays });
+ }
+
+ if (overbroadScopes.length) {
+ hold(decision, "overbroad_scopes", "Remove scopes outside the enterprise allowlist.");
+ blockers.push({ ...finding("overbroad_scopes", integration.id, "Integration has scopes outside the allowlist."), overbroadScopes });
+ }
+
+ if (auditAgeDays > policy.maxAuditExportAgeDays) {
+ hold(decision, "stale_audit_export", "Generate a fresh audit export before the next sync.");
+ blockers.push({ ...finding("stale_audit_export", integration.id, "Audit export is outside the recency window."), auditAgeDays });
+ }
+
+ if (integration.ssoGroupDrift > policy.maxSsoGroupDrift) {
+ hold(decision, "sso_group_drift", "Reconcile SSO group membership before syncing permissions.");
+ blockers.push({ ...finding("sso_group_drift", integration.id, "SSO group drift exceeds policy."), ssoGroupDrift: integration.ssoGroupDrift });
+ }
+
+ if (integration.restrictedData && !integration.dpaApproved) {
+ hold(decision, "missing_dpa_approval", "Attach DPA approval before syncing restricted enterprise data.");
+ blockers.push(finding("missing_dpa_approval", integration.id, "Restricted-data integration lacks DPA approval."));
+ }
+
+ if (integration.webhookFailures >= 3) {
+ warnings.push({ ...finding("webhook_failure_streak", integration.id, "Webhook delivery has repeated failures."), webhookFailures: integration.webhookFailures });
+ }
+
+ decisions.push(decision);
+ }
+
+ const status = blockers.length ? "hold_integration" : warnings.length ? "needs_admin_review" : "integration_ready";
+ return {
+ status,
+ generatedAt: now.toISOString(),
+ packetId: normalized.packetId,
+ digest: digest({ packetId: normalized.packetId, blockers, warnings, decisions }),
+ counts: {
+ integrations: normalized.integrations.length,
+ owners: normalized.owners.length,
+ blockers: blockers.length,
+ warnings: warnings.length,
+ heldIntegrations: decisions.filter((item) => item.decision === "hold").length
+ },
+ blockers,
+ warnings,
+ decisions,
+ policy
+ };
+}
+
+export function buildReviewerPacket(packet, options = {}) {
+ return {
+ title: "SCIBASE Enterprise Token Rotation Guard",
+ issue: "SCIBASE.AI#19",
+ claim: "/claim #19",
+ evaluation: evaluateEnterpriseIntegrations(packet, options),
+ reviewerChecklist: [
+ "Integration tokens are inside the rotation window.",
+ "Scopes are limited to the enterprise allowlist.",
+ "Every integration has an active institutional owner.",
+ "Audit exports are fresh before enterprise syncs.",
+ "Restricted data integrations have DPA approval."
+ ]
+ };
+}
+
+export function renderMarkdownReport(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return [
+ "# Enterprise Token Rotation Guard Report",
+ "",
+ `Issue: ${review.issue}`,
+ `Claim marker: \`${review.claim}\``,
+ `Status: \`${evaluation.status}\``,
+ `Digest: \`${evaluation.digest}\``,
+ "",
+ "## Reviewer Checklist",
+ ...review.reviewerChecklist.map((item) => `- ${item}`),
+ "",
+ "## Blockers",
+ ...(evaluation.blockers.length ? evaluation.blockers.map((item) => `- ${item.code}: ${item.message}`) : ["- None."]),
+ "",
+ "## Integration Decisions",
+ ...evaluation.decisions.map((item) => `- ${item.integrationId}: ${item.decision}${item.reasons.length ? ` (${item.reasons.join(", ")})` : ""}`)
+ ].join("\n") + "\n";
+}
+
+export function renderSvgSummary(packet, options = {}) {
+ const review = buildReviewerPacket(packet, options);
+ const { evaluation } = review;
+ return `\n`;
+}
+
+export function demoPacket() {
+ return {
+ packetId: "scibase-enterprise-token-demo",
+ generatedAt: "2026-06-13T19:00:00.000Z",
+ owners: [
+ { id: "owner-active", name: "Research IT Admin", active: true },
+ { id: "owner-left", name: "Former Lab Admin", active: false }
+ ],
+ integrations: [
+ { id: "int-dspace-sync", tenant: "North Campus", ownerId: "owner-active", tokenIssuedAt: "2026-05-15T00:00:00.000Z", scopes: ["project:read", "webhook:publish", "audit:read"], lastAuditExportAt: "2026-06-10T00:00:00.000Z", ssoGroupDrift: 1, restrictedData: false, dpaApproved: false, webhookFailures: 0 },
+ { id: "int-hris-sync", tenant: "Medical Research Institute", ownerId: "owner-left", tokenIssuedAt: "2026-01-01T00:00:00.000Z", scopes: ["project:read", "admin:root"], lastAuditExportAt: "2026-05-20T00:00:00.000Z", ssoGroupDrift: 6, restrictedData: true, dpaApproved: false, webhookFailures: 4 }
+ ]
+ };
+}
+
+export function normalizePacket(packet) {
+ if (!packet || typeof packet !== "object") throw new TypeError("An enterprise integration packet object is required.");
+ return {
+ packetId: text(packet.packetId, "packetId"),
+ generatedAt: text(packet.generatedAt, "generatedAt"),
+ policy: packet.policy ?? {},
+ owners: asArray(packet.owners, "owners").map((owner) => ({
+ id: text(owner.id, "owner.id"),
+ name: text(owner.name, "owner.name"),
+ active: Boolean(owner.active)
+ })),
+ integrations: asArray(packet.integrations, "integrations").map((integration) => ({
+ id: text(integration.id, "integration.id"),
+ tenant: text(integration.tenant, "integration.tenant"),
+ ownerId: text(integration.ownerId, "integration.ownerId"),
+ tokenIssuedAt: text(integration.tokenIssuedAt, "integration.tokenIssuedAt"),
+ scopes: asArray(integration.scopes, "integration.scopes").map((scope) => text(scope, "integration.scope")),
+ lastAuditExportAt: text(integration.lastAuditExportAt, "integration.lastAuditExportAt"),
+ ssoGroupDrift: number(integration.ssoGroupDrift, "integration.ssoGroupDrift"),
+ restrictedData: Boolean(integration.restrictedData),
+ dpaApproved: Boolean(integration.dpaApproved),
+ webhookFailures: number(integration.webhookFailures ?? 0, "integration.webhookFailures")
+ }))
+ };
+}
+
+function hold(decision, reason, requiredAction) { decision.decision = "hold"; decision.reasons.push(reason); decision.requiredActions.push(requiredAction); }
+function finding(code, integrationId, message) { return { code, integrationId, message }; }
+function daysBetween(start, end) { return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000)); }
+function text(value, name) { if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string.`); return value.trim(); }
+function number(value, name) { const parsed = Number(value); if (!Number.isFinite(parsed)) throw new TypeError(`${name} must be finite.`); return parsed; }
+function asArray(value, name) { if (!Array.isArray(value)) throw new TypeError(`${name} must be an array.`); return value; }
+function digest(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
+function escapeXml(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """); }
diff --git a/enterprise-token-rotation-guard/test/index.test.js b/enterprise-token-rotation-guard/test/index.test.js
new file mode 100644
index 00000000..400f406f
--- /dev/null
+++ b/enterprise-token-rotation-guard/test/index.test.js
@@ -0,0 +1,59 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ buildReviewerPacket,
+ demoPacket,
+ evaluateEnterpriseIntegrations,
+ normalizePacket,
+ renderMarkdownReport,
+ renderSvgSummary
+} from "../src/index.js";
+
+test("holds stale tokens, orphan owners, and overbroad scopes", () => {
+ const result = evaluateEnterpriseIntegrations(demoPacket(), { now: "2026-06-13T19:00:00.000Z" });
+ assert.equal(result.status, "hold_integration");
+ assert.ok(result.blockers.some((blocker) => blocker.code === "orphan_owner"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "stale_token"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "overbroad_scopes"));
+});
+
+test("holds stale audit exports, SSO drift, and missing DPA approval", () => {
+ const result = evaluateEnterpriseIntegrations(demoPacket(), { now: "2026-06-13T19:00:00.000Z" });
+ assert.ok(result.blockers.some((blocker) => blocker.code === "stale_audit_export"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "sso_group_drift"));
+ assert.ok(result.blockers.some((blocker) => blocker.code === "missing_dpa_approval"));
+});
+
+test("releases clean enterprise integration packets", () => {
+ const packet = demoPacket();
+ packet.integrations = [packet.integrations[0]];
+ const result = evaluateEnterpriseIntegrations(packet, { now: "2026-06-13T19:00:00.000Z" });
+ assert.equal(result.status, "integration_ready");
+ assert.equal(result.counts.blockers, 0);
+ assert.equal(result.counts.heldIntegrations, 0);
+});
+
+test("warns on repeated webhook delivery failures", () => {
+ const packet = demoPacket();
+ packet.integrations = [{ ...packet.integrations[0], webhookFailures: 3 }];
+ const result = evaluateEnterpriseIntegrations(packet, { now: "2026-06-13T19:00:00.000Z" });
+ assert.equal(result.status, "needs_admin_review");
+ assert.ok(result.warnings.some((warning) => warning.code === "webhook_failure_streak"));
+});
+
+test("rejects malformed packets and renders claim artifacts", () => {
+ assert.equal(normalizePacket(demoPacket()).integrations.length, 2);
+ const broken = demoPacket();
+ broken.integrations[0].scopes = "project:read";
+ assert.throws(() => normalizePacket(broken), /integration\.scopes must be an array/);
+
+ const packet = demoPacket();
+ const review = buildReviewerPacket(packet, { now: packet.generatedAt });
+ const markdown = renderMarkdownReport(packet, { now: packet.generatedAt });
+ const svg = renderSvgSummary(packet, { now: packet.generatedAt });
+ assert.equal(review.claim, "/claim #19");
+ assert.match(markdown, /Enterprise Token Rotation Guard Report/);
+ assert.match(markdown, /`\/claim #19`/);
+ assert.match(svg, /