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
4 changes: 4 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions cta-xp-report/README.md
Original file line number Diff line number Diff line change
@@ -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:<org>`).
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.
10 changes: 10 additions & 0 deletions cta-xp-report/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
124 changes: 124 additions & 0 deletions cta-xp-report/src/ctaReportService.js
Original file line number Diff line number Diff line change
@@ -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" };
}
Comment on lines +47 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce free-run guardrail with atomic reservation

The one-free-report gate can be bypassed under concurrent requests because eligibility (canRunFreeReport) and persistence (recordFreeReportRun) are split into separate operations: two requests for the same owner can both see no record and both proceed before either write lands. In the integration flow described in this commit (check, then dispatch, then store), this allows duplicate free runs for one org; use a transactional check-and-set (or reserve the key before dispatch) to close the race.

Useful? React with 👍 / 👎.


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,
};
82 changes: 82 additions & 0 deletions cta-xp-report/test/ctaReportService.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});