Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions analytics-api-license-usage-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions analytics-api-license-usage-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -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
```
31 changes: 31 additions & 0 deletions analytics-api-license-usage-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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}`);
310 changes: 310 additions & 0 deletions analytics-api-license-usage-guard/index.js
Original file line number Diff line number Diff line change
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

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 `<text x="58" y="${y + 23}" class="label">${escapeXml(status)}</text>
<rect x="256" y="${y}" width="${width}" height="28" rx="5" class="bar"/>
<text x="${268 + width}" y="${y + 22}" class="value">${count}</text>`;
})
.join("\n ");

return `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<style>
.bg{fill:#111827}
.panel{fill:#f8fafc}
.title{font:700 34px Arial,sans-serif;fill:#0f172a}
.meta{font:400 18px Arial,sans-serif;fill:#475569}
.label{font:600 20px Arial,sans-serif;fill:#1e293b}
.value{font:700 20px Arial,sans-serif;fill:#0f172a}
.bar{fill:#10b981}
.digest{font:400 15px monospace;fill:#334155}
</style>
<rect class="bg" width="960" height="540"/>
<rect class="panel" x="28" y="28" width="904" height="484" rx="16"/>
<text x="58" y="82" class="title">Analytics API License Usage Guard</text>
<text x="58" y="116" class="meta">Requests ${result.totals.totalRequests} | Billable units ${result.totals.billableUnits}</text>
${rows}
<text x="58" y="482" class="digest">audit ${escapeXml(result.auditDigest.slice(0, 48))}</text>
</svg>
`;
}

module.exports = {
ALLOW,
BILL_OVERAGE,
THROTTLE,
BLOCK,
analyzeAnalyticsApiUsage,
createAuditDigest,
renderMarkdownReport,
renderSvgSummary,
};
Loading