From 87d1ca9a61078aa10f5b350a63c9d117aa5f0c04 Mon Sep 17 00:00:00 2001 From: RyanAI Date: Wed, 4 Mar 2026 05:30:28 +0800 Subject: [PATCH] feat: add CTA XP report automation prototype for issue 196 --- README.MD | 4 + cta-xp-report/README.md | 27 +++++ cta-xp-report/package.json | 10 ++ cta-xp-report/src/ctaReportService.js | 124 ++++++++++++++++++++ cta-xp-report/test/ctaReportService.test.js | 82 +++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 cta-xp-report/README.md create mode 100644 cta-xp-report/package.json create mode 100644 cta-xp-report/src/ctaReportService.js create mode 100644 cta-xp-report/test/ctaReportService.test.js diff --git a/README.MD b/README.MD index 8b137891..0aaefbfa 100644 --- a/README.MD +++ b/README.MD @@ -1 +1,5 @@ +# business-development +## Prototypes + +- `cta-xp-report/` — reference implementation for issue [#196](https://github.com/ubiquity/business-development/issues/196): automated CTA XP report dispatch flow with one-org free-run gating, signed dispatch payloads, and email/report-link composition. diff --git a/cta-xp-report/README.md b/cta-xp-report/README.md new file mode 100644 index 00000000..70259bbd --- /dev/null +++ b/cta-xp-report/README.md @@ -0,0 +1,27 @@ +# CTA XP Report Prototype (Issue #196) + +This package provides a minimal, testable reference implementation for the workflow described in: + +- https://github.com/ubiquity/business-development/issues/196 + +## What it solves + +1. **Input validation** for GitHub repository URL or `owner/repo`. +2. **One free report per organization** guardrail via KV-backed keying. +3. **Signed dispatch payload** (`sha256` HMAC) for secure workflow invocation. +4. **Deterministic report link + email message** for delayed delivery UX. + +## Suggested integration flow + +1. Landing page submits repository + email. +2. API validates repo and checks KV (`free-report:`). +3. API dispatches `text-conversation-rewards` workflow with signed payload. +4. API stores run metadata and sends email with ETA + report link. + +## Run tests + +```bash +npm test +``` + +No external dependencies are required. diff --git a/cta-xp-report/package.json b/cta-xp-report/package.json new file mode 100644 index 00000000..ab36d61e --- /dev/null +++ b/cta-xp-report/package.json @@ -0,0 +1,10 @@ +{ + "name": "cta-xp-report-prototype", + "version": "0.1.0", + "private": true, + "description": "Prototype service logic for issue #196 CTA report automation", + "type": "commonjs", + "scripts": { + "test": "node --test" + } +} diff --git a/cta-xp-report/src/ctaReportService.js b/cta-xp-report/src/ctaReportService.js new file mode 100644 index 00000000..e6da6dd6 --- /dev/null +++ b/cta-xp-report/src/ctaReportService.js @@ -0,0 +1,124 @@ +const crypto = require("node:crypto"); + +const DEFAULT_COOLDOWN_DAYS = 36500; // effectively once per org for free path + +function normalizeRepository(input) { + if (!input || typeof input !== "string") { + throw new Error("Repository URL is required"); + } + + const trimmed = input.trim(); + const regex = /^(?:https?:\/\/github\.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git|\/)?$/i; + const match = trimmed.match(regex); + + if (!match) { + throw new Error(`Invalid GitHub repository: ${input}`); + } + + return { + owner: match[1], + repo: match[2], + fullName: `${match[1]}/${match[2]}`, + }; +} + +function organizationKey(owner) { + return `free-report:${owner.toLowerCase()}`; +} + +class InMemoryKvStore { + constructor(seed = {}) { + this.map = new Map(Object.entries(seed)); + } + + get(key) { + return this.map.get(key); + } + + set(key, value) { + this.map.set(key, value); + } +} + +function canRunFreeReport({ kv, owner, now = Date.now(), cooldownDays = DEFAULT_COOLDOWN_DAYS }) { + const key = organizationKey(owner); + const existing = kv.get(key); + + if (!existing) { + return { allowed: true, reason: "first_run" }; + } + + const elapsedMs = now - existing.startedAt; + const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000; + + if (elapsedMs >= cooldownMs) { + return { allowed: true, reason: "cooldown_expired" }; + } + + return { + allowed: false, + reason: "already_claimed", + nextEligibleAt: existing.startedAt + cooldownMs, + }; +} + +function recordFreeReportRun({ kv, owner, repo, requesterEmail, runId, now = Date.now() }) { + const key = organizationKey(owner); + const value = { + owner, + repo, + requesterEmail, + runId, + startedAt: now, + }; + + kv.set(key, value); + return value; +} + +function signDispatchPayload(payload, sharedSecret) { + if (!sharedSecret) { + throw new Error("sharedSecret is required"); + } + + const body = JSON.stringify(payload); + const signature = crypto.createHmac("sha256", sharedSecret).update(body).digest("hex"); + + return { + body, + signature, + signatureHeader: `sha256=${signature}`, + }; +} + +function buildReportLink({ dashboardBaseUrl, owner, repo, runId }) { + const encodedRepo = encodeURIComponent(`${owner}/${repo}`); + return `${dashboardBaseUrl.replace(/\/$/, "")}/reports/${encodedRepo}?runId=${encodeURIComponent(runId)}`; +} + +function composeEmail({ owner, repo, runId, reportLink, etaMinutes = 60 }) { + return { + subject: `Your free XP report is processing for ${owner}/${repo}`, + text: [ + `Thanks for trying the XP report preview for ${owner}/${repo}.`, + "", + `Run ID: ${runId}`, + `Estimated completion: ~${etaMinutes} minutes`, + `Report link: ${reportLink}`, + "", + "We currently allow one free org-level report to prevent abuse.", + ].join("\n"), + }; +} + +module.exports = { + DEFAULT_COOLDOWN_DAYS, + InMemoryKvStore, + normalizeRepository, + organizationKey, + canRunFreeReport, + recordFreeReportRun, + signDispatchPayload, + buildReportLink, + composeEmail, +}; diff --git a/cta-xp-report/test/ctaReportService.test.js b/cta-xp-report/test/ctaReportService.test.js new file mode 100644 index 00000000..9ad6bc9d --- /dev/null +++ b/cta-xp-report/test/ctaReportService.test.js @@ -0,0 +1,82 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + InMemoryKvStore, + normalizeRepository, + canRunFreeReport, + recordFreeReportRun, + signDispatchPayload, + buildReportLink, + composeEmail, +} = require("../src/ctaReportService"); + +test("normalizeRepository parses both URL and owner/repo forms", () => { + assert.deepEqual(normalizeRepository("https://github.com/ubiquity/business-development"), { + owner: "ubiquity", + repo: "business-development", + fullName: "ubiquity/business-development", + }); + + assert.deepEqual(normalizeRepository("ubiquity/business-development"), { + owner: "ubiquity", + repo: "business-development", + fullName: "ubiquity/business-development", + }); +}); + +test("free report can only run once per org during cooldown window", () => { + const kv = new InMemoryKvStore(); + const now = Date.UTC(2026, 2, 4, 0, 0, 0); + + const first = canRunFreeReport({ kv, owner: "ubiquity", now }); + assert.equal(first.allowed, true); + + recordFreeReportRun({ + kv, + owner: "ubiquity", + repo: "business-development", + requesterEmail: "test@example.com", + runId: "run_001", + now, + }); + + const second = canRunFreeReport({ kv, owner: "ubiquity", now: now + 3600_000 }); + assert.equal(second.allowed, false); + assert.equal(second.reason, "already_claimed"); + assert.ok(second.nextEligibleAt > now); +}); + +test("signDispatchPayload generates deterministic signature header", () => { + const payload = { owner: "ubiquity", repo: "business-development", runId: "r1" }; + const signed = signDispatchPayload(payload, "shh-secret"); + + assert.ok(signed.signature.length > 40); + assert.equal(signed.signatureHeader.startsWith("sha256="), true); + + const again = signDispatchPayload(payload, "shh-secret"); + assert.equal(signed.signature, again.signature); +}); + +test("report link + email formatter includes run metadata", () => { + const link = buildReportLink({ + dashboardBaseUrl: "https://xp.ubq.fi/", + owner: "ubiquity", + repo: "business-development", + runId: "run_123", + }); + + assert.equal(link, "https://xp.ubq.fi/reports/ubiquity%2Fbusiness-development?runId=run_123"); + + const email = composeEmail({ + owner: "ubiquity", + repo: "business-development", + runId: "run_123", + reportLink: link, + etaMinutes: 45, + }); + + assert.match(email.subject, /ubiquity\/business-development/); + assert.match(email.text, /run_123/); + assert.match(email.text, /45 minutes/); +});