From 373e0678b638809ee650c35d2625ef33a266df5c Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Fri, 22 May 2026 17:41:55 +0530 Subject: [PATCH] Add analytics license seat roster guard --- analytics-license-seat-roster-guard/README.md | 28 ++ .../demo-video.js | 173 +++++++++++ analytics-license-seat-roster-guard/demo.js | 18 ++ analytics-license-seat-roster-guard/index.js | 294 ++++++++++++++++++ .../package.json | 14 + .../reports/demo.webm | Bin 0 -> 11202 bytes .../reports/reviewer-packet.md | 42 +++ .../reports/summary.json | 153 +++++++++ .../reports/summary.svg | 16 + .../requirements-map.md | 18 ++ .../sample-data.js | 120 +++++++ analytics-license-seat-roster-guard/test.js | 55 ++++ 12 files changed, 931 insertions(+) create mode 100644 analytics-license-seat-roster-guard/README.md create mode 100644 analytics-license-seat-roster-guard/demo-video.js create mode 100644 analytics-license-seat-roster-guard/demo.js create mode 100644 analytics-license-seat-roster-guard/index.js create mode 100644 analytics-license-seat-roster-guard/package.json create mode 100644 analytics-license-seat-roster-guard/reports/demo.webm create mode 100644 analytics-license-seat-roster-guard/reports/reviewer-packet.md create mode 100644 analytics-license-seat-roster-guard/reports/summary.json create mode 100644 analytics-license-seat-roster-guard/reports/summary.svg create mode 100644 analytics-license-seat-roster-guard/requirements-map.md create mode 100644 analytics-license-seat-roster-guard/sample-data.js create mode 100644 analytics-license-seat-roster-guard/test.js 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 0000000000000000000000000000000000000000..8402e55d2dc1dbc85f0ac2c337c7409388e422a6 GIT binary patch literal 11202 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 literal 0 HcmV?d00001 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("