diff --git a/analytics-license-seat-roster-guard/README.md b/analytics-license-seat-roster-guard/README.md new file mode 100644 index 00000000..a0fd9907 --- /dev/null +++ b/analytics-license-seat-roster-guard/README.md @@ -0,0 +1,28 @@ +# Analytics License Seat Roster Guard + +Self-contained Revenue Infrastructure slice for `SCIBASE-AI/SCIBASE.AI#20`. + +The guard reconciles named analytics dashboard and API seats before renewal or +true-up billing. It checks contracted seat classes, allowed domains, temporary +access windows, inactive paid seats, API usage by non-API seats, duplicate +identities, and finance approvals so revenue leakage can be fixed before +renewal invoices are sent. + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Outputs + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic. The module does not call payment processors, SSO, SCIM, +ERP, analytics APIs, billing systems, or external services. diff --git a/analytics-license-seat-roster-guard/demo-video.js b/analytics-license-seat-roster-guard/demo-video.js new file mode 100644 index 00000000..613a111d --- /dev/null +++ b/analytics-license-seat-roster-guard/demo-video.js @@ -0,0 +1,173 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const chromeCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = chromeCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + + + + Analytics license seat roster guard demo + + + + +
recording
+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "analytics-license-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7000", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); diff --git a/analytics-license-seat-roster-guard/demo.js b/analytics-license-seat-roster-guard/demo.js new file mode 100644 index 00000000..8c919701 --- /dev/null +++ b/analytics-license-seat-roster-guard/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { project } = require("./sample-data"); +const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const packet = buildReviewPacket(project); + +fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8"); +fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8"); +fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8"); + +console.log(`Generated reports for ${packet.guard}`); +console.log(`Decision: ${packet.decision}`); +console.log(`Estimated exposure: $${packet.estimatedRevenueExposure}`); +console.log(`Findings: ${packet.findings.length}`); diff --git a/analytics-license-seat-roster-guard/index.js b/analytics-license-seat-roster-guard/index.js new file mode 100644 index 00000000..a5938704 --- /dev/null +++ b/analytics-license-seat-roster-guard/index.js @@ -0,0 +1,294 @@ +const SEVERITY_WEIGHTS = { + critical: 40, + high: 24, + medium: 12, + low: 5 +}; + +function daysBetween(a, b) { + const left = new Date(a).getTime(); + const right = new Date(b).getTime(); + return Math.floor((right - left) / (24 * 60 * 60 * 1000)); +} + +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +function domainFromEmail(email) { + const value = normalizeEmail(email); + return value.includes("@") ? value.split("@").pop() : ""; +} + +function addFinding(findings, severity, rule, message, action, exposure = 0, userIds = []) { + findings.push({ + severity, + rule, + message, + action, + estimatedExposure: Math.round(exposure * 100) / 100, + userIds + }); +} + +function isDomainApproved(domain, approvals) { + return approvals.domainApprovals.some((approval) => approval.domain === domain && approval.status === "approved"); +} + +function hasOverageApproval(seatClass, approvals) { + return approvals.overageApprovals.some( + (approval) => approval.seatClass === seatClass && approval.status === "approved" + ); +} + +function isTemporaryAccessApproved(user, approvals, asOfDate) { + const approval = approvals.temporaryAccessApprovals.find((item) => item.userId === user.id); + if (!approval || approval.status !== "approved") { + return false; + } + return daysBetween(asOfDate, approval.expiresAt) >= 0; +} + +function groupActiveSeats(users) { + return users + .filter((user) => user.status === "active") + .reduce((totals, user) => { + totals[user.seatClass] = (totals[user.seatClass] || 0) + 1; + return totals; + }, {}); +} + +function findDuplicateIdentities(users) { + const byEmail = new Map(); + for (const user of users.filter((item) => item.status === "active")) { + const key = normalizeEmail(user.email); + if (!byEmail.has(key)) { + byEmail.set(key, []); + } + byEmail.get(key).push(user); + } + return [...byEmail.values()].filter((items) => items.length > 1); +} + +function evaluateRoster(project) { + const findings = []; + const { contract, roster, usage, approvals } = project; + const activeBySeatClass = groupActiveSeats(roster.users); + const allowedDomains = new Set(contract.allowedDomains); + + for (const [seatClass, entitlement] of Object.entries(contract.seatEntitlements)) { + const activeCount = activeBySeatClass[seatClass] || 0; + const overage = activeCount - entitlement; + if (overage > 0 && !hasOverageApproval(seatClass, approvals)) { + addFinding( + findings, + "high", + "unapproved-seat-overage", + `${activeCount} active ${seatClass} seats exceed the contracted ${entitlement} seat entitlement by ${overage}.`, + "Hold renewal true-up until finance approves overage billing or seats are reclaimed.", + overage * (contract.seatRates[seatClass] || 0), + roster.users.filter((user) => user.status === "active" && user.seatClass === seatClass).map((user) => user.id) + ); + } + } + + for (const user of roster.users) { + if (user.status !== "active") { + continue; + } + + const domain = domainFromEmail(user.email); + if (!allowedDomains.has(domain) && !isDomainApproved(domain, approvals)) { + addFinding( + findings, + "critical", + "unapproved-seat-domain", + `${user.email} uses domain ${domain}, which is outside the signed analytics license domains.`, + "Remove the seat or attach a signed domain addendum before renewal billing.", + contract.seatRates[user.seatClass] || 0, + [user.id] + ); + } + + if (user.temporaryUntil && daysBetween(project.asOfDate, user.temporaryUntil) < 0 && !isTemporaryAccessApproved(user, approvals, project.asOfDate)) { + addFinding( + findings, + "medium", + "expired-temporary-access", + `${user.email} still has active access after temporary access expired on ${user.temporaryUntil}.`, + "Disable the temporary seat or convert it into a paid named seat before renewal.", + contract.seatRates[user.seatClass] || 0, + [user.id] + ); + } + + if (daysBetween(user.lastSeenAt, project.asOfDate) > contract.inactivityReclaimDays) { + addFinding( + findings, + "low", + "inactive-paid-seat", + `${user.email} has not used analytics access since ${user.lastSeenAt}.`, + "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.", + contract.seatRates[user.seatClass] || 0, + [user.id] + ); + } + + const usageRecord = usage.byUser[user.id] || { apiQueries: 0, dashboardSessions: 0 }; + if (usageRecord.apiQueries > 0 && user.seatClass !== "api") { + addFinding( + findings, + "high", + "api-usage-without-api-seat", + `${user.email} generated ${usageRecord.apiQueries} analytics API queries while assigned to a ${user.seatClass} seat.`, + "Reclassify the user to an API seat or remove API keys before billing the renewal period.", + contract.seatRates.api - (contract.seatRates[user.seatClass] || 0), + [user.id] + ); + } + } + + for (const duplicateGroup of findDuplicateIdentities(roster.users)) { + addFinding( + findings, + "medium", + "duplicate-named-seat", + `${duplicateGroup[0].email} appears as ${duplicateGroup.length} active named seats.`, + "Collapse duplicate identity records before seat counts are sent to finance.", + (duplicateGroup.length - 1) * (contract.seatRates[duplicateGroup[0].seatClass] || 0), + duplicateGroup.map((user) => user.id) + ); + } + + const totalExposure = findings.reduce((sum, finding) => sum + finding.estimatedExposure, 0); + const severitySummary = findings.reduce( + (summary, finding) => { + summary[finding.severity] += 1; + return summary; + }, + { critical: 0, high: 0, medium: 0, low: 0 } + ); + + return { + activeBySeatClass, + contractedSeatEntitlements: contract.seatEntitlements, + findings, + severitySummary, + estimatedRevenueExposure: Math.round(totalExposure * 100) / 100, + score: Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0)) + }; +} + +function decisionFromScore(score, severitySummary) { + if (severitySummary.critical > 0) { + return "block-renewal-until-seat-evidence-is-clean"; + } + if (score < 70) { + return "hold-renewal-true-up-for-finance-review"; + } + if (score < 88) { + return "review-seat-exceptions-before-invoice"; + } + return "renewal-roster-ready"; +} + +function buildFinanceActions(findings) { + return findings.map((finding) => ({ + priority: finding.severity === "critical" || finding.severity === "high" ? "blocking" : "review", + rule: finding.rule, + action: finding.action, + estimatedExposure: finding.estimatedExposure, + userIds: finding.userIds + })); +} + +function buildReviewPacket(project) { + const evaluation = evaluateRoster(project); + return { + guard: "analytics-license-seat-roster-guard", + issue: "SCIBASE-AI/SCIBASE.AI#20", + customer: project.contract.customer, + asOfDate: project.asOfDate, + renewalDate: project.contract.renewalDate, + decision: decisionFromScore(evaluation.score, evaluation.severitySummary), + score: evaluation.score, + activeBySeatClass: evaluation.activeBySeatClass, + contractedSeatEntitlements: evaluation.contractedSeatEntitlements, + estimatedRevenueExposure: evaluation.estimatedRevenueExposure, + findings: evaluation.findings, + financeActions: buildFinanceActions(evaluation.findings), + safety: [ + "Synthetic roster and usage data only", + "No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls", + "No private customer data, payment credentials, tax IDs, or live invoice mutations" + ] + }; +} + +function renderMarkdownReport(packet) { + const lines = [ + `# Analytics License Seat Roster Guard`, + ``, + `Customer: ${packet.customer}`, + `Issue: ${packet.issue}`, + `Decision: ${packet.decision}`, + `Score: ${packet.score}`, + `Estimated revenue exposure: $${packet.estimatedRevenueExposure}`, + ``, + `## Seat Counts`, + ``, + `| Seat class | Active | Contracted |`, + `| --- | ---: | ---: |` + ]; + + for (const seatClass of Object.keys(packet.contractedSeatEntitlements)) { + lines.push( + `| ${seatClass} | ${packet.activeBySeatClass[seatClass] || 0} | ${packet.contractedSeatEntitlements[seatClass]} |` + ); + } + + lines.push(``, `## Findings`, ``); + for (const finding of packet.findings) { + lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`); + lines.push(` - Action: ${finding.action}`); + lines.push(` - Exposure: $${finding.estimatedExposure}`); + } + + lines.push(``, `## Safety`, ``); + for (const item of packet.safety) { + lines.push(`- ${item}`); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(packet) { + const exposureWidth = Math.min(760, Math.max(40, packet.estimatedRevenueExposure / 12)); + const criticalCount = packet.findings.filter((finding) => finding.severity === "critical").length; + const highCount = packet.findings.filter((finding) => finding.severity === "high").length; + return ` + + Analytics License Seat Roster Guard + ${packet.customer} renewal evidence packet + + ${packet.decision} + Score ${packet.score} | Critical ${criticalCount} | High ${highCount} + + Estimated revenue exposure + + + $${packet.estimatedRevenueExposure} + + Synthetic-only finance review + No live billing, SSO, SCIM, payment processor, or private customer data. + +`; +} + +module.exports = { + buildReviewPacket, + decisionFromScore, + evaluateRoster, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/analytics-license-seat-roster-guard/package.json b/analytics-license-seat-roster-guard/package.json new file mode 100644 index 00000000..6e019300 --- /dev/null +++ b/analytics-license-seat-roster-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "analytics-license-seat-roster-guard", + "version": "1.0.0", + "description": "Deterministic revenue guard for analytics license seat roster true-up and leakage review.", + "main": "index.js", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + } +} diff --git a/analytics-license-seat-roster-guard/reports/demo.webm b/analytics-license-seat-roster-guard/reports/demo.webm new file mode 100644 index 00000000..8402e55d Binary files /dev/null and b/analytics-license-seat-roster-guard/reports/demo.webm differ diff --git a/analytics-license-seat-roster-guard/reports/reviewer-packet.md b/analytics-license-seat-roster-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..70ffdd12 --- /dev/null +++ b/analytics-license-seat-roster-guard/reports/reviewer-packet.md @@ -0,0 +1,42 @@ +# Analytics License Seat Roster Guard + +Customer: Northbridge Research Consortium +Issue: SCIBASE-AI/SCIBASE.AI#20 +Decision: block-renewal-until-seat-evidence-is-clean +Score: 0 +Estimated revenue exposure: $8750 + +## Seat Counts + +| Seat class | Active | Contracted | +| --- | ---: | ---: | +| dashboard | 5 | 4 | +| api | 2 | 2 | +| viewer | 3 | 5 | + +## Findings + +- **high / unapproved-seat-overage**: 5 active dashboard seats exceed the contracted 4 seat entitlement by 1. + - Action: Hold renewal true-up until finance approves overage billing or seats are reclaimed. + - Exposure: $1200 +- **low / inactive-paid-seat**: liam.gray@northbridge.edu has not used analytics access since 2025-12-18. + - Action: Queue the seat for renewal roster confirmation or reclaim before the true-up invoice. + - Exposure: $1200 +- **critical / unapproved-seat-domain**: visiting.pi@partner-lab.com uses domain partner-lab.com, which is outside the signed analytics license domains. + - Action: Remove the seat or attach a signed domain addendum before renewal billing. + - Exposure: $1200 +- **medium / expired-temporary-access**: visiting.pi@partner-lab.com still has active access after temporary access expired on 2026-04-30. + - Action: Disable the temporary seat or convert it into a paid named seat before renewal. + - Exposure: $1200 +- **high / api-usage-without-api-seat**: policy-api@northbridge.edu generated 1300 analytics API queries while assigned to a viewer seat. + - Action: Reclassify the user to an API seat or remove API keys before billing the renewal period. + - Exposure: $2750 +- **medium / duplicate-named-seat**: maya.chen@northbridge.edu appears as 2 active named seats. + - Action: Collapse duplicate identity records before seat counts are sent to finance. + - Exposure: $1200 + +## Safety + +- Synthetic roster and usage data only +- No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls +- No private customer data, payment credentials, tax IDs, or live invoice mutations diff --git a/analytics-license-seat-roster-guard/reports/summary.json b/analytics-license-seat-roster-guard/reports/summary.json new file mode 100644 index 00000000..cf727d33 --- /dev/null +++ b/analytics-license-seat-roster-guard/reports/summary.json @@ -0,0 +1,153 @@ +{ + "guard": "analytics-license-seat-roster-guard", + "issue": "SCIBASE-AI/SCIBASE.AI#20", + "customer": "Northbridge Research Consortium", + "asOfDate": "2026-05-22", + "renewalDate": "2026-06-01", + "decision": "block-renewal-until-seat-evidence-is-clean", + "score": 0, + "activeBySeatClass": { + "dashboard": 5, + "api": 2, + "viewer": 3 + }, + "contractedSeatEntitlements": { + "dashboard": 4, + "api": 2, + "viewer": 5 + }, + "estimatedRevenueExposure": 8750, + "findings": [ + { + "severity": "high", + "rule": "unapproved-seat-overage", + "message": "5 active dashboard seats exceed the contracted 4 seat entitlement by 1.", + "action": "Hold renewal true-up until finance approves overage billing or seats are reclaimed.", + "estimatedExposure": 1200, + "userIds": [ + "seat-001", + "seat-002", + "seat-003", + "seat-004", + "seat-005" + ] + }, + { + "severity": "low", + "rule": "inactive-paid-seat", + "message": "liam.gray@northbridge.edu has not used analytics access since 2025-12-18.", + "action": "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.", + "estimatedExposure": 1200, + "userIds": [ + "seat-003" + ] + }, + { + "severity": "critical", + "rule": "unapproved-seat-domain", + "message": "visiting.pi@partner-lab.com uses domain partner-lab.com, which is outside the signed analytics license domains.", + "action": "Remove the seat or attach a signed domain addendum before renewal billing.", + "estimatedExposure": 1200, + "userIds": [ + "seat-005" + ] + }, + { + "severity": "medium", + "rule": "expired-temporary-access", + "message": "visiting.pi@partner-lab.com still has active access after temporary access expired on 2026-04-30.", + "action": "Disable the temporary seat or convert it into a paid named seat before renewal.", + "estimatedExposure": 1200, + "userIds": [ + "seat-005" + ] + }, + { + "severity": "high", + "rule": "api-usage-without-api-seat", + "message": "policy-api@northbridge.edu generated 1300 analytics API queries while assigned to a viewer seat.", + "action": "Reclassify the user to an API seat or remove API keys before billing the renewal period.", + "estimatedExposure": 2750, + "userIds": [ + "seat-007" + ] + }, + { + "severity": "medium", + "rule": "duplicate-named-seat", + "message": "maya.chen@northbridge.edu appears as 2 active named seats.", + "action": "Collapse duplicate identity records before seat counts are sent to finance.", + "estimatedExposure": 1200, + "userIds": [ + "seat-001", + "seat-010" + ] + } + ], + "financeActions": [ + { + "priority": "blocking", + "rule": "unapproved-seat-overage", + "action": "Hold renewal true-up until finance approves overage billing or seats are reclaimed.", + "estimatedExposure": 1200, + "userIds": [ + "seat-001", + "seat-002", + "seat-003", + "seat-004", + "seat-005" + ] + }, + { + "priority": "review", + "rule": "inactive-paid-seat", + "action": "Queue the seat for renewal roster confirmation or reclaim before the true-up invoice.", + "estimatedExposure": 1200, + "userIds": [ + "seat-003" + ] + }, + { + "priority": "blocking", + "rule": "unapproved-seat-domain", + "action": "Remove the seat or attach a signed domain addendum before renewal billing.", + "estimatedExposure": 1200, + "userIds": [ + "seat-005" + ] + }, + { + "priority": "review", + "rule": "expired-temporary-access", + "action": "Disable the temporary seat or convert it into a paid named seat before renewal.", + "estimatedExposure": 1200, + "userIds": [ + "seat-005" + ] + }, + { + "priority": "blocking", + "rule": "api-usage-without-api-seat", + "action": "Reclassify the user to an API seat or remove API keys before billing the renewal period.", + "estimatedExposure": 2750, + "userIds": [ + "seat-007" + ] + }, + { + "priority": "review", + "rule": "duplicate-named-seat", + "action": "Collapse duplicate identity records before seat counts are sent to finance.", + "estimatedExposure": 1200, + "userIds": [ + "seat-001", + "seat-010" + ] + } + ], + "safety": [ + "Synthetic roster and usage data only", + "No Stripe, PayPal, bank, ACH, ERP, SSO, SCIM, or analytics provider calls", + "No private customer data, payment credentials, tax IDs, or live invoice mutations" + ] +} diff --git a/analytics-license-seat-roster-guard/reports/summary.svg b/analytics-license-seat-roster-guard/reports/summary.svg new file mode 100644 index 00000000..e2d6688b --- /dev/null +++ b/analytics-license-seat-roster-guard/reports/summary.svg @@ -0,0 +1,16 @@ + + + Analytics License Seat Roster Guard + Northbridge Research Consortium renewal evidence packet + + block-renewal-until-seat-evidence-is-clean + Score 0 | Critical 1 | High 2 + + Estimated revenue exposure + + + $8750 + + Synthetic-only finance review + No live billing, SSO, SCIM, payment processor, or private customer data. + diff --git a/analytics-license-seat-roster-guard/requirements-map.md b/analytics-license-seat-roster-guard/requirements-map.md new file mode 100644 index 00000000..dbb02eea --- /dev/null +++ b/analytics-license-seat-roster-guard/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +Issue: `SCIBASE-AI/SCIBASE.AI#20` + +| Issue requirement | Implementation | +| --- | --- | +| Licensing APIs and analytics revenue | Validates named analytics dashboard/API seats against contract terms before renewal billing. | +| Institutional customers | Sample data models a consortium contract with allowed domains, seat classes, temporary access, and finance approvals. | +| Periodic analytics dashboard access | Checks active dashboard/viewer seats, inactive paid seats, duplicate identities, and temporary access expiry. | +| API access to graph metadata | Detects analytics API usage by users without API seat rights, while avoiding live query serving or API authorization. | +| Revenue infrastructure | Produces finance actions, estimated revenue exposure, and renewal true-up decisions. | +| Safe local validation | Includes dependency-free tests, demo report generation, SVG summary, and browser-generated demo video. | + +## Non-goals + +- No Stripe, PayPal, ACH, bank, tax, ERP, or live invoice actions. +- No SSO, SCIM, HRIS, analytics API, or dashboard provider calls. +- No private customer data, credentials, or real payment artifacts. diff --git a/analytics-license-seat-roster-guard/sample-data.js b/analytics-license-seat-roster-guard/sample-data.js new file mode 100644 index 00000000..bf661827 --- /dev/null +++ b/analytics-license-seat-roster-guard/sample-data.js @@ -0,0 +1,120 @@ +const project = { + asOfDate: "2026-05-22", + contract: { + customer: "Northbridge Research Consortium", + renewalDate: "2026-06-01", + allowedDomains: ["northbridge.edu", "nrc-labs.org"], + inactivityReclaimDays: 90, + seatEntitlements: { + dashboard: 4, + api: 2, + viewer: 5 + }, + seatRates: { + dashboard: 1200, + api: 3200, + viewer: 450 + } + }, + approvals: { + domainApprovals: [ + { domain: "nrc-labs.org", status: "approved", evidenceId: "addendum-2026-02" } + ], + overageApprovals: [], + temporaryAccessApprovals: [ + { userId: "seat-009", status: "approved", expiresAt: "2026-06-15" } + ] + }, + roster: { + users: [ + { + id: "seat-001", + email: "maya.chen@northbridge.edu", + seatClass: "dashboard", + status: "active", + lastSeenAt: "2026-05-21" + }, + { + id: "seat-002", + email: "omar.patel@northbridge.edu", + seatClass: "dashboard", + status: "active", + lastSeenAt: "2026-05-17" + }, + { + id: "seat-003", + email: "liam.gray@northbridge.edu", + seatClass: "dashboard", + status: "active", + lastSeenAt: "2025-12-18" + }, + { + id: "seat-004", + email: "grant.ops@northbridge.edu", + seatClass: "dashboard", + status: "active", + lastSeenAt: "2026-05-12" + }, + { + id: "seat-005", + email: "visiting.pi@partner-lab.com", + seatClass: "dashboard", + status: "active", + temporaryUntil: "2026-04-30", + lastSeenAt: "2026-05-20" + }, + { + id: "seat-006", + email: "data-api@nrc-labs.org", + seatClass: "api", + status: "active", + lastSeenAt: "2026-05-22" + }, + { + id: "seat-007", + email: "policy-api@northbridge.edu", + seatClass: "viewer", + status: "active", + lastSeenAt: "2026-05-19" + }, + { + id: "seat-008", + email: "archive-api@nrc-labs.org", + seatClass: "api", + status: "active", + lastSeenAt: "2026-05-18" + }, + { + id: "seat-009", + email: "reviewer.temp@northbridge.edu", + seatClass: "viewer", + status: "active", + temporaryUntil: "2026-06-10", + lastSeenAt: "2026-05-16" + }, + { + id: "seat-010", + email: "maya.chen@northbridge.edu", + seatClass: "viewer", + status: "active", + lastSeenAt: "2026-05-20" + } + ] + }, + usage: { + byUser: { + "seat-001": { dashboardSessions: 14, apiQueries: 0 }, + "seat-002": { dashboardSessions: 8, apiQueries: 0 }, + "seat-003": { dashboardSessions: 0, apiQueries: 0 }, + "seat-004": { dashboardSessions: 6, apiQueries: 0 }, + "seat-005": { dashboardSessions: 2, apiQueries: 0 }, + "seat-006": { dashboardSessions: 1, apiQueries: 4200 }, + "seat-007": { dashboardSessions: 3, apiQueries: 1300 }, + "seat-008": { dashboardSessions: 1, apiQueries: 900 }, + "seat-009": { dashboardSessions: 2, apiQueries: 0 }, + "seat-010": { dashboardSessions: 1, apiQueries: 0 } + } + } +}; + +module.exports = { project }; diff --git a/analytics-license-seat-roster-guard/test.js b/analytics-license-seat-roster-guard/test.js new file mode 100644 index 00000000..7cfed875 --- /dev/null +++ b/analytics-license-seat-roster-guard/test.js @@ -0,0 +1,55 @@ +const assert = require("assert"); +const { project } = require("./sample-data"); +const { buildReviewPacket, evaluateRoster, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const evaluation = evaluateRoster(project); +const packet = buildReviewPacket(project); + +assert.strictEqual(packet.guard, "analytics-license-seat-roster-guard"); +assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#20"); +assert.strictEqual(packet.decision, "block-renewal-until-seat-evidence-is-clean"); +assert.ok(packet.estimatedRevenueExposure >= 6000, "expected material revenue exposure"); + +assert.ok( + evaluation.findings.some((finding) => finding.rule === "unapproved-seat-overage"), + "expected dashboard seat overage finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "unapproved-seat-domain"), + "expected unapproved external domain finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "api-usage-without-api-seat"), + "expected API usage without API seat finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "duplicate-named-seat"), + "expected duplicate named seat finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "inactive-paid-seat"), + "expected inactive paid seat finding" +); + +const cleanProject = JSON.parse(JSON.stringify(project)); +cleanProject.contract.seatEntitlements.dashboard = 5; +cleanProject.contract.seatEntitlements.viewer = 6; +cleanProject.contract.allowedDomains.push("partner-lab.com"); +cleanProject.roster.users = cleanProject.roster.users.filter((user) => user.id !== "seat-010"); +cleanProject.roster.users.find((user) => user.id === "seat-003").lastSeenAt = "2026-05-01"; +cleanProject.roster.users.find((user) => user.id === "seat-005").temporaryUntil = "2026-06-30"; +cleanProject.roster.users.find((user) => user.id === "seat-007").seatClass = "api"; +cleanProject.contract.seatEntitlements.api = 3; +const cleanPacket = buildReviewPacket(cleanProject); +assert.strictEqual(cleanPacket.decision, "renewal-roster-ready"); +assert.strictEqual(cleanPacket.findings.length, 0); + +const markdown = renderMarkdownReport(packet); +assert.ok(markdown.includes("## Seat Counts")); +assert.ok(markdown.includes("unapproved-seat-domain")); + +const svg = renderSvgSummary(packet); +assert.ok(svg.includes("