From 16f869c5203367573c660b06ed2a0d187f6014bb Mon Sep 17 00:00:00 2001 From: Ethan Miller Date: Fri, 22 May 2026 03:12:32 -0400 Subject: [PATCH] Add analytics API license usage guard --- analytics-api-license-usage-guard/README.md | 33 ++ .../acceptance-notes.md | 20 ++ analytics-api-license-usage-guard/demo.js | 31 ++ analytics-api-license-usage-guard/index.js | 310 ++++++++++++++++++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 22007 bytes .../reports/reviewer-packet.md | 84 +++++ .../reports/summary.json | 131 ++++++++ .../reports/summary.svg | 26 ++ .../requirements-map.md | 18 + .../sample-data.js | 129 ++++++++ analytics-api-license-usage-guard/test.js | 52 +++ 12 files changed, 847 insertions(+) create mode 100644 analytics-api-license-usage-guard/README.md create mode 100644 analytics-api-license-usage-guard/acceptance-notes.md create mode 100644 analytics-api-license-usage-guard/demo.js create mode 100644 analytics-api-license-usage-guard/index.js create mode 100644 analytics-api-license-usage-guard/package.json create mode 100644 analytics-api-license-usage-guard/reports/demo.mp4 create mode 100644 analytics-api-license-usage-guard/reports/reviewer-packet.md create mode 100644 analytics-api-license-usage-guard/reports/summary.json create mode 100644 analytics-api-license-usage-guard/reports/summary.svg create mode 100644 analytics-api-license-usage-guard/requirements-map.md create mode 100644 analytics-api-license-usage-guard/sample-data.js create mode 100644 analytics-api-license-usage-guard/test.js diff --git a/analytics-api-license-usage-guard/README.md b/analytics-api-license-usage-guard/README.md new file mode 100644 index 00000000..43ec2d96 --- /dev/null +++ b/analytics-api-license-usage-guard/README.md @@ -0,0 +1,33 @@ +# Analytics API License Usage Guard + +This module provides a focused Revenue Infrastructure slice for SCIBASE issue +#20. It validates paid analytics API customers before graph metadata queries are +served or billed. + +## What It Covers + +- License tier and active-license checks. +- Dataset and topic entitlement checks. +- Anonymization threshold enforcement for metadata-only exports. +- Private-content blocking. +- Monthly quota and overage authorization checks. +- Per-minute burst throttling. +- Deterministic revenue and audit packets for usage dashboards. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +Generated artifacts are written to `reports/`: + +- `summary.json` +- `reviewer-packet.md` +- `summary.svg` +- `demo.mp4` + +All data is synthetic and does not call Stripe, PayPal, customer systems, live +billing providers, or private analytics stores. diff --git a/analytics-api-license-usage-guard/acceptance-notes.md b/analytics-api-license-usage-guard/acceptance-notes.md new file mode 100644 index 00000000..e0202e7b --- /dev/null +++ b/analytics-api-license-usage-guard/acceptance-notes.md @@ -0,0 +1,20 @@ +# Acceptance Notes + +The guard classifies analytics API requests as: + +- `allow`: query is licensed, anonymous, inside quota, and billable. +- `bill_overage`: query can be served with authorized overage billing. +- `throttle`: burst or quota limits prevent immediate service. +- `block`: inactive license, disallowed package/topic, private content, or + anonymization failure. + +Validation commands: + +```bash +npm run check +npm test +npm run demo +ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height,pix_fmt -of default=noprint_wrappers=1 reports/demo.mp4 +git diff --check +git diff --cached --check +``` diff --git a/analytics-api-license-usage-guard/demo.js b/analytics-api-license-usage-guard/demo.js new file mode 100644 index 00000000..657fd11d --- /dev/null +++ b/analytics-api-license-usage-guard/demo.js @@ -0,0 +1,31 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeAnalyticsApiUsage, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleAnalyticsApiPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = analyzeAnalyticsApiUsage(sampleAnalyticsApiPacket, { + asOf: "2026-05-22T12:00:00.000Z", +}); + +fs.writeFileSync( + path.join(reportsDir, "summary.json"), + `${JSON.stringify(result, null, 2)}\n` +); +fs.writeFileSync( + path.join(reportsDir, "reviewer-packet.md"), + renderMarkdownReport(result) +); +fs.writeFileSync( + path.join(reportsDir, "summary.svg"), + renderSvgSummary(result) +); + +console.log("analytics API license usage guard demo artifacts written"); +console.log(`audit digest: ${result.auditDigest}`); diff --git a/analytics-api-license-usage-guard/index.js b/analytics-api-license-usage-guard/index.js new file mode 100644 index 00000000..94825f28 --- /dev/null +++ b/analytics-api-license-usage-guard/index.js @@ -0,0 +1,310 @@ +const crypto = require("node:crypto"); + +const ALLOW = "allow"; +const BILL_OVERAGE = "bill_overage"; +const THROTTLE = "throttle"; +const BLOCK = "block"; + +function requireFields(value, fields, label) { + for (const field of fields) { + if (value[field] === undefined || value[field] === null || value[field] === "") { + throw new Error(`missing required ${label} field: ${field}`); + } + } +} + +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 createAuditDigest(result) { + const payload = { + asOf: result.asOf, + totals: result.totals, + decisions: result.decisions.map((decision) => ({ + id: decision.id, + customerId: decision.customerId, + status: decision.status, + billableUnits: decision.billableUnits, + reasons: decision.reasons, + })), + }; + + return crypto.createHash("sha256").update(stableStringify(payload)).digest("hex"); +} + +function licenseLookup(licenses) { + const lookup = new Map(); + for (const license of licenses) { + requireFields( + license, + [ + "customerId", + "tier", + "active", + "monthlyQuota", + "usedThisMonth", + "burstLimitPerMinute", + "allowedDatasets", + "allowedTopics", + "minimumCohortSize", + "overageAuthorized", + ], + "license" + ); + lookup.set(license.customerId, license); + } + return lookup; +} + +function evaluateRequest(request, licenses) { + requireFields( + request, + [ + "id", + "customerId", + "dataset", + "topic", + "estimatedRows", + "cohortSize", + "queryUnits", + "requestsLastMinute", + "includesPrivateContent", + "requestedAt", + ], + "analytics request" + ); + + const license = licenses.get(request.customerId); + const reasons = []; + const actions = []; + const base = { + id: request.id, + customerId: request.customerId, + dataset: request.dataset, + topic: request.topic, + queryUnits: Number(request.queryUnits), + billableUnits: 0, + status: BLOCK, + reasons, + actions, + }; + + if (!license) { + return { + ...base, + reasons: ["customer has no analytics API license"], + actions: ["Block query and route account to revenue operations"], + }; + } + + if (!license.active) { + reasons.push("analytics API license is inactive"); + } + if (!license.allowedDatasets.includes(request.dataset)) { + reasons.push("dataset is not included in the licensed analytics package"); + } + if (!license.allowedTopics.includes(request.topic)) { + reasons.push("topic is outside licensed usage scope"); + } + if (request.includesPrivateContent) { + reasons.push("query attempts to access private content rather than anonymized metadata"); + } + if (request.cohortSize < license.minimumCohortSize) { + reasons.push("cohort size is below anonymization threshold"); + } + + if (reasons.length > 0) { + return { + ...base, + status: BLOCK, + reasons, + actions: [ + "Block analytics response", + "Log revenue and privacy audit event", + "Show permitted datasets, topics, and anonymization requirements", + ], + }; + } + + if (request.requestsLastMinute > license.burstLimitPerMinute) { + return { + ...base, + status: THROTTLE, + reasons: ["request burst exceeds licensed per-minute API limit"], + actions: [ + "Throttle query before execution", + "Preserve request for API usage dashboard", + ], + }; + } + + const projectedUsage = Number(license.usedThisMonth) + Number(request.queryUnits); + if (projectedUsage > Number(license.monthlyQuota)) { + if (!license.overageAuthorized) { + return { + ...base, + status: THROTTLE, + reasons: ["monthly query quota would be exceeded and overage is not authorized"], + actions: [ + "Throttle query until renewal or account approval", + "Offer overage authorization workflow", + ], + }; + } + + return { + ...base, + status: BILL_OVERAGE, + billableUnits: base.queryUnits, + reasons: ["query exceeds quota but overage billing is authorized"], + actions: [ + "Serve anonymized analytics response", + "Record overage units for invoice line item", + ], + }; + } + + return { + ...base, + status: ALLOW, + billableUnits: base.queryUnits, + reasons: ["query is inside license, privacy, quota, and burst limits"], + actions: [ + "Serve anonymized analytics response", + "Record billable usage units", + ], + }; +} + +function analyzeAnalyticsApiUsage(packet, options = {}) { + requireFields(packet, ["licenses", "requests"], "analytics API packet"); + if (!Array.isArray(packet.licenses) || !Array.isArray(packet.requests)) { + throw new Error("licenses and requests must be arrays"); + } + + const licenses = licenseLookup(packet.licenses); + const decisions = packet.requests.map((request) => evaluateRequest(request, licenses)); + const totals = decisions.reduce( + (acc, decision) => { + acc.totalRequests += 1; + acc.billableUnits += decision.billableUnits; + acc.byStatus[decision.status] = (acc.byStatus[decision.status] || 0) + 1; + return acc; + }, + { + totalRequests: 0, + billableUnits: 0, + byStatus: {}, + } + ); + const result = { + asOf: options.asOf || packet.asOf || new Date().toISOString(), + totals, + decisions, + }; + + return { + ...result, + auditDigest: createAuditDigest(result), + }; +} + +function renderMarkdownReport(result) { + const lines = [ + "# Analytics API License Usage Guard", + "", + `As of: ${result.asOf}`, + `Audit digest: \`${result.auditDigest}\``, + "", + "## Totals", + "", + `- Requests evaluated: ${result.totals.totalRequests}`, + `- Billable units: ${result.totals.billableUnits}`, + ]; + + for (const [status, count] of Object.entries(result.totals.byStatus).sort()) { + lines.push(`- ${status}: ${count}`); + } + + lines.push("", "## Query Decisions", ""); + for (const decision of result.decisions) { + lines.push( + `### ${decision.id}`, + "", + `- Customer: ${decision.customerId}`, + `- Dataset: ${decision.dataset}`, + `- Topic: ${decision.topic}`, + `- Status: ${decision.status}`, + `- Billable units: ${decision.billableUnits}`, + `- Reasons: ${decision.reasons.join("; ")}`, + `- Actions: ${decision.actions.join("; ")}`, + "" + ); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderSvgSummary(result) { + const rows = Object.entries(result.totals.byStatus) + .sort() + .map(([status, count], index) => { + const y = 154 + index * 46; + const width = Math.max(44, count * 92); + return `${escapeXml(status)} + + ${count}`; + }) + .join("\n "); + + return ` + + + + Analytics API License Usage Guard + Requests ${result.totals.totalRequests} | Billable units ${result.totals.billableUnits} + ${rows} + audit ${escapeXml(result.auditDigest.slice(0, 48))} + +`; +} + +module.exports = { + ALLOW, + BILL_OVERAGE, + THROTTLE, + BLOCK, + analyzeAnalyticsApiUsage, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +}; diff --git a/analytics-api-license-usage-guard/package.json b/analytics-api-license-usage-guard/package.json new file mode 100644 index 00000000..abb0f1e2 --- /dev/null +++ b/analytics-api-license-usage-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "analytics-api-license-usage-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic analytics API license usage guard for SCIBASE issue #20.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js" + }, + "license": "MIT" +} diff --git a/analytics-api-license-usage-guard/reports/demo.mp4 b/analytics-api-license-usage-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..82071f02a2911d5d2a11ce50165ed41e580449b0 GIT binary patch literal 22007 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 XiLH-H9+d(hZ+ + + + + Analytics API License Usage Guard + Requests 7 | Billable units 120 + allow + + 1 + block + + 4 + throttle + + 2 + audit 56f75fde23be7ba664e9e122c1c43530a39beeb8dc3bb96d + diff --git a/analytics-api-license-usage-guard/requirements-map.md b/analytics-api-license-usage-guard/requirements-map.md new file mode 100644 index 00000000..f211037d --- /dev/null +++ b/analytics-api-license-usage-guard/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +| Issue #20 area | Coverage | +| --- | --- | +| Licensing APIs & Analytics | Validates licensed metadata API query access before serving graph analytics. | +| Usage-based pricing | Records billable query units and blocks unapproved overage. | +| Real-time usage meters | Applies monthly quota and burst-limit enforcement. | +| Data licensing models | Enforces dataset, topic, and anonymized-metadata restrictions. | +| Secure payment integrations | Avoids live payment systems and emits audit packets that can feed finance review. | + +## Non-overlap + +This is not a generic billing, metering ledger, tax, dispute, SLA, royalty, +forecast, FX, plan migration, procurement, invoice acceptance, +sanctions/export-control, payment failover/webhook, trial/promotion, storage +overage, account transfer, collections, or prepaid compute credit breakage +slice. It focuses on paid analytics API license usage and query entitlement +gating. diff --git a/analytics-api-license-usage-guard/sample-data.js b/analytics-api-license-usage-guard/sample-data.js new file mode 100644 index 00000000..7c73fd63 --- /dev/null +++ b/analytics-api-license-usage-guard/sample-data.js @@ -0,0 +1,129 @@ +const sampleAnalyticsApiPacket = { + asOf: "2026-05-22T12:00:00.000Z", + licenses: [ + { + customerId: "cust-nih-policy", + tier: "government_policy", + active: true, + monthlyQuota: 10000, + usedThisMonth: 4200, + burstLimitPerMinute: 120, + allowedDatasets: ["citation_networks", "dataset_reuse", "reproducibility_scores"], + allowedTopics: ["public_health", "open_science", "grant_outcomes"], + minimumCohortSize: 25, + overageAuthorized: true, + }, + { + customerId: "cust-market-intel", + tier: "market_intelligence", + active: true, + monthlyQuota: 2500, + usedThisMonth: 2480, + burstLimitPerMinute: 40, + allowedDatasets: ["topic_trends", "method_usage"], + allowedTopics: ["materials", "biotech"], + minimumCohortSize: 50, + overageAuthorized: false, + }, + { + customerId: "cust-expired-consortium", + tier: "academic_consortium", + active: false, + monthlyQuota: 5000, + usedThisMonth: 1200, + burstLimitPerMinute: 80, + allowedDatasets: ["citation_networks"], + allowedTopics: ["open_science"], + minimumCohortSize: 30, + overageAuthorized: false, + }, + ], + requests: [ + { + id: "query-001", + customerId: "cust-nih-policy", + dataset: "citation_networks", + topic: "public_health", + estimatedRows: 800, + cohortSize: 80, + queryUnits: 120, + requestsLastMinute: 16, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:00:00.000Z", + }, + { + id: "query-002", + customerId: "cust-market-intel", + dataset: "dataset_reuse", + topic: "biotech", + estimatedRows: 200, + cohortSize: 80, + queryUnits: 70, + requestsLastMinute: 18, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:04:00.000Z", + }, + { + id: "query-003", + customerId: "cust-market-intel", + dataset: "topic_trends", + topic: "biotech", + estimatedRows: 140, + cohortSize: 18, + queryUnits: 60, + requestsLastMinute: 12, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:08:00.000Z", + }, + { + id: "query-004", + customerId: "cust-market-intel", + dataset: "method_usage", + topic: "materials", + estimatedRows: 100, + cohortSize: 65, + queryUnits: 40, + requestsLastMinute: 52, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:10:00.000Z", + }, + { + id: "query-005", + customerId: "cust-market-intel", + dataset: "method_usage", + topic: "materials", + estimatedRows: 250, + cohortSize: 65, + queryUnits: 75, + requestsLastMinute: 20, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:12:00.000Z", + }, + { + id: "query-006", + customerId: "cust-expired-consortium", + dataset: "citation_networks", + topic: "open_science", + estimatedRows: 150, + cohortSize: 90, + queryUnits: 35, + requestsLastMinute: 5, + includesPrivateContent: false, + requestedAt: "2026-05-22T10:14:00.000Z", + }, + { + id: "query-007", + customerId: "cust-nih-policy", + dataset: "dataset_reuse", + topic: "grant_outcomes", + estimatedRows: 120, + cohortSize: 64, + queryUnits: 130, + requestsLastMinute: 10, + includesPrivateContent: true, + requestedAt: "2026-05-22T10:16:00.000Z", + }, + ], +}; + +module.exports = { sampleAnalyticsApiPacket }; diff --git a/analytics-api-license-usage-guard/test.js b/analytics-api-license-usage-guard/test.js new file mode 100644 index 00000000..78126d39 --- /dev/null +++ b/analytics-api-license-usage-guard/test.js @@ -0,0 +1,52 @@ +const assert = require("node:assert/strict"); +const { + ALLOW, + BLOCK, + THROTTLE, + analyzeAnalyticsApiUsage, + createAuditDigest, + renderMarkdownReport, + renderSvgSummary, +} = require("./index"); +const { sampleAnalyticsApiPacket } = require("./sample-data"); + +const result = analyzeAnalyticsApiUsage(sampleAnalyticsApiPacket); + +assert.equal(result.totals.totalRequests, 7); +assert.equal(result.totals.billableUnits, 120); +assert.equal(result.totals.byStatus[ALLOW], 1); +assert.equal(result.totals.byStatus[BLOCK], 4); +assert.equal(result.totals.byStatus[THROTTLE], 2); + +const allowed = result.decisions.find((decision) => decision.id === "query-001"); +assert.equal(allowed.status, ALLOW); +assert.equal(allowed.billableUnits, 120); + +const datasetBlock = result.decisions.find((decision) => decision.id === "query-002"); +assert.equal(datasetBlock.status, BLOCK); +assert.match(datasetBlock.reasons.join(" "), /not included/); + +const cohortBlock = result.decisions.find((decision) => decision.id === "query-003"); +assert.equal(cohortBlock.status, BLOCK); +assert.match(cohortBlock.reasons.join(" "), /anonymization threshold/); + +const burstThrottle = result.decisions.find((decision) => decision.id === "query-004"); +assert.equal(burstThrottle.status, THROTTLE); + +const quotaThrottle = result.decisions.find((decision) => decision.id === "query-005"); +assert.equal(quotaThrottle.status, THROTTLE); + +const privateBlock = result.decisions.find((decision) => decision.id === "query-007"); +assert.equal(privateBlock.status, BLOCK); +assert.match(privateBlock.reasons.join(" "), /private content/); + +assert.equal(createAuditDigest(result), result.auditDigest); +assert.match(renderMarkdownReport(result), /Analytics API License Usage Guard/); +assert.match(renderSvgSummary(result), /Billable units 120/); + +assert.throws( + () => analyzeAnalyticsApiUsage({ licenses: [], requests: [{}] }), + /missing required analytics request field: id/ +); + +console.log("analytics API license usage guard tests passed");