diff --git a/HOSTED.md b/HOSTED.md
new file mode 100644
index 0000000..684efbf
--- /dev/null
+++ b/HOSTED.md
@@ -0,0 +1,50 @@
+# Hosted pubskill service
+
+The hosted surface at `https://pubskill.interdependentway.org/` preserves two product questions:
+
+- **Audit:** Which repository claims hold up, and which fail against the evidence?
+- **Examiner:** What is actually in this codebase, how is it structured and documented, what can be measured safely, and where are the unresolved boundaries?
+
+## Audit purchases
+
+The hosted audit supports two purchase sizes:
+
+- one complete repository audit: **$5 USD**
+- five complete repository audits: **$20 USD**
+
+A single repository may first receive a three-finding free preview. Paid checkout is created only after the repository URL or five repository URLs have been supplied. The exact URLs and purchase tier are bound into Stripe Checkout Session metadata before payment; the return handler verifies live payment state, amount, tier, count, and repository URLs before running the complete audits.
+
+Supported public HTTPS Git hosts are GitHub, GitLab, Bitbucket, Codeberg, and SourceHut. Target repository code is not executed.
+
+## Operator access
+
+`PUBSKILL_OPERATOR_CODE` configures a private server-side access code for demonstration and operator use. The value must be supplied as deployment secret/environment state; it must not be committed to this repository. The service compares the submitted code server-side and never stores it in repository state or browser storage.
+
+Operator access runs the same complete one- or five-repository audit paths without creating a Stripe checkout.
+
+## Deployment configuration
+
+Required for paid checkout:
+
+```text
+STRIPE_SECRET_KEY
+```
+
+Required for private operator bypass:
+
+```text
+PUBSKILL_OPERATOR_CODE
+```
+
+Optional hosted URL overrides:
+
+```text
+PUBSKILL_SUCCESS_URL
+PUBSKILL_CANCEL_URL
+```
+
+The defaults return successful checkout to `https://pubskill.interdependentway.org/?session_id={CHECKOUT_SESSION_ID}` and cancellation to the site root.
+
+## Examiner boundary
+
+Structural examination does not require AI. AI is optional for narration. Hosted Examiner execution, metering, and pricing remain `hmmm` until measured against real repository runs; the hosted site must not imply those capabilities are already available.
diff --git a/index.html b/index.html
index 291e745..e6f0a8c 100644
--- a/index.html
+++ b/index.html
@@ -13,19 +13,19 @@
.pubskill-head { margin-bottom: 2rem; }
.pubskill-head h1 { font-size: clamp(2.8rem, 8vw, 5.8rem); margin-bottom: .8rem; }
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin: 1.5rem 0 2rem; }
- .choice-card { display: flex; flex-direction: column; min-height: 18rem; }
+ .choice-card { display: flex; flex-direction: column; min-height: 15rem; }
.choice-card h2 { margin: .25rem 0 .7rem; }
.choice-card p { color: var(--silver); }
.choice-card .question { color: var(--starlight); font-family: Georgia, "Times New Roman", serif; font-size: clamp(1.2rem, 2.4vw, 1.55rem); line-height: 1.35; }
.choice-card .actions { margin-top: auto; }
.choice-card[aria-current="true"] { border-color: var(--violet); box-shadow: var(--shadow); }
.product-panel { margin-top: 1.5rem; }
- .product-panel[hidden] { display: none; }
+ .product-panel[hidden], .tier-panel[hidden], .operator-panel[hidden] { display: none; }
.audit-panel { padding: clamp(1.2rem, 4vw, 2rem); background: linear-gradient(145deg, rgba(22,33,58,.9), rgba(16,24,42,.86)); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
- .audit-panel label { display: block; margin-bottom: .55rem; color: var(--cyan); font: 700 .76rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .12em; }
- .repo-field { width: 100%; min-height: 3.2rem; padding: .8rem .95rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--night); color: var(--starlight); font: inherit; }
- .repo-field::placeholder { color: #6f7d98; }
- .repo-field:focus { border-color: var(--violet); outline: 3px solid rgba(155,135,245,.18); outline-offset: 2px; }
+ .audit-panel label { display: block; margin: .8rem 0 .4rem; color: var(--cyan); font: 700 .76rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .12em; }
+ .repo-field, .code-field { width: 100%; min-height: 3.2rem; padding: .8rem .95rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--night); color: var(--starlight); font: inherit; }
+ .repo-field::placeholder, .code-field::placeholder { color: #6f7d98; }
+ .repo-field:focus, .code-field:focus { border-color: var(--violet); outline: 3px solid rgba(155,135,245,.18); outline-offset: 2px; }
.audit-actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1rem; }
button.button { cursor: pointer; font: inherit; }
#status { min-height: 1.6rem; margin: 1rem 0 0; color: var(--silver); }
@@ -35,11 +35,14 @@
.finding > strong { color: var(--cyan); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .02em; }
.finding p { color: var(--starlight); }
.meta { color: var(--silver); font-size: .88rem; overflow-wrap: anywhere; }
- .buy { margin-top: .8rem; }
.support-row { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; }
.examiner-choices { margin-top: 1rem; }
.examiner-choices .card { min-height: 11rem; }
.boundary-note { margin-top: 1rem; }
+ .tier-choices .card { min-height: 11rem; }
+ .tier-panel { margin-top: 1rem; }
+ .access-row { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--line); }
+ .batch-result { margin: 1rem 0 1.5rem; }
.site-footer a { color: var(--silver); }
@media (max-width: 780px) {
.pubskill-head h1 { font-size: clamp(2.55rem, 13vw, 4.2rem); }
@@ -76,7 +79,7 @@
What do you need to know?
Audit
Repository Audit
Which repository claims hold up, and which fail against the evidence?
- Three findings are free. Unlock the complete audit for $5. Target code is not executed.
+ Three findings are free. Full audits can be purchased for one repository or five at a time.
@@ -102,15 +105,62 @@ Which repository claims hold up, and which fail against the
-
-
-
+
+
+ One repository
+ $5 full audit
+ Run the free three-finding preview first, then unlock the complete result.
+
+
+
+ Five repositories
+ $20 bundle
+ Bind five public repository URLs to one checkout and receive five complete audits.
+
+
+
+
+
+
+
+
+
+
+
+
+
Complete audit
+
+
+
+
+
+
+
+
+
+
+
Five repositories · $20
+
+
+
+
+
+
+
+
+
+
+
+ Operator access
+
+
-
+
+
-
-
+
+
@@ -158,6 +208,27 @@ hmmm
const examinerPanel = document.getElementById("examiner-panel");
const auditChoice = document.getElementById("audit-choice");
const examinerChoice = document.getElementById("examiner-choice");
+ const singleChoice = document.getElementById("single-choice");
+ const bundleChoice = document.getElementById("bundle-choice");
+ const singlePanel = document.getElementById("single-panel");
+ const bundlePanel = document.getElementById("bundle-panel");
+ const operatorPanel = document.getElementById("operator-panel");
+ const operatorCode = document.getElementById("operator-code");
+ let activeTier = "single";
+
+ const bundleFields = document.getElementById("bundle-fields");
+ for (let index = 1; index <= 5; index += 1) {
+ const label = document.createElement("label");
+ label.htmlFor = `repo-${index}`;
+ label.textContent = `Repository ${index}`;
+ const input = document.createElement("input");
+ input.className = "repo-field bundle-repo";
+ input.id = `repo-${index}`;
+ input.inputMode = "url";
+ input.autocomplete = "url";
+ input.placeholder = `https://github.com/owner/repo-${index}.git`;
+ bundleFields.append(label, input);
+ }
function selectProduct(product) {
const auditSelected = product === "audit";
@@ -168,6 +239,24 @@ hmmm
(auditSelected ? auditPanel : examinerPanel).scrollIntoView({behavior: "smooth", block: "start"});
}
+ function selectTier(tier) {
+ activeTier = tier;
+ const single = tier === "single";
+ singlePanel.hidden = !single;
+ bundlePanel.hidden = single;
+ singleChoice.setAttribute("aria-current", String(single));
+ bundleChoice.setAttribute("aria-current", String(!single));
+ operatorPanel.hidden = true;
+ operatorCode.value = "";
+ status.textContent = "";
+ out.replaceChildren();
+ }
+
+ function repoUrlsFor(tier) {
+ if (tier === "single") return [repo.value.trim()];
+ return [...document.querySelectorAll(".bundle-repo")].map(input => input.value.trim());
+ }
+
function add(tag, text, parent = out, className = "") {
const node = document.createElement(tag);
node.textContent = text;
@@ -176,58 +265,86 @@ hmmm
return node;
}
- function clear() {
+ function clearResults() {
status.textContent = "";
out.replaceChildren();
}
- function renderFinding(f) {
+ function renderFinding(f, parent) {
const card = document.createElement("article");
card.className = "card finding";
add("strong", `${f.id} — ${f.surface}`, card);
add("p", f.claim, card);
add("div", `Evidence: ${f.evidence}`, card, "meta");
add("div", `Class: ${f.class}`, card, "meta");
- out.appendChild(card);
+ parent.appendChild(card);
}
- function renderResult(r, paid = false) {
- clear();
- add("p", paid ? "full audit" : "free preview", out, "eyebrow");
- add("h2", r.target?.remote || "Repository");
- add("p", `Commit: ${r.target?.commit || "unknown"}`, out, "meta");
-
- const count = paid ? (r.findings?.length || 0) : (r.total_findings ?? r.findings?.length ?? 0);
- add("h3", `${count} findings${paid ? "" : " total"}`);
-
- for (const finding of r.findings || []) renderFinding(finding);
-
- if (paid && r.hmmm?.length) {
+ function renderFullResult(result, parent) {
+ add("h3", result.target?.remote || "Repository", parent);
+ add("p", `Commit: ${result.target?.commit || "unknown"}`, parent, "meta");
+ add("p", `${result.findings?.length || 0} findings`, parent);
+ for (const finding of result.findings || []) renderFinding(finding, parent);
+ if (result.hmmm?.length) {
const boundary = document.createElement("section");
boundary.className = "hmmm";
add("h3", "hmmm", boundary);
const list = document.createElement("ul");
- for (const item of r.hmmm) add("li", item, list);
+ for (const item of result.hmmm) add("li", item, list);
boundary.appendChild(list);
- out.appendChild(boundary);
+ parent.appendChild(boundary);
+ }
+ }
+
+ function renderBatch(payload) {
+ clearResults();
+ add("p", payload.operator ? "operator audit" : "paid audit", out, "eyebrow");
+ add("h2", `${payload.repository_count} complete audit${payload.repository_count === 1 ? "" : "s"}`);
+ for (const item of payload.audits || []) {
+ const section = document.createElement("section");
+ section.className = "panel batch-result";
+ if (item.error) {
+ add("h3", item.repo_url, section);
+ add("p", item.error, section);
+ } else {
+ renderFullResult(item.result, section);
+ }
+ out.appendChild(section);
}
+ }
- if (!paid && r.locked) {
+ function renderPreview(r) {
+ clearResults();
+ add("p", "free preview", out, "eyebrow");
+ add("h2", r.target?.remote || "Repository");
+ add("p", `Commit: ${r.target?.commit || "unknown"}`, out, "meta");
+ add("h3", `${r.total_findings ?? r.findings?.length ?? 0} findings total`);
+ for (const finding of r.findings || []) renderFinding(finding, out);
+ if (r.locked) {
const gate = document.createElement("section");
gate.className = "panel";
add("h3", "Full audit", gate);
- add("p", `Preview shows the first ${r.findings.length}. Full audit also includes remaining findings and ${r.hmmm_count || 0} unresolved constraint(s).`, gate);
- const buy = document.createElement("a");
- buy.href = r.payment_link;
- buy.className = "button buy";
- buy.textContent = "Unlock full audit — $5";
- gate.appendChild(buy);
+ add("p", `Preview shows the first ${r.findings.length}. The complete audit also includes remaining findings and ${r.hmmm_count || 0} unresolved constraint(s).`, gate);
+ const actions = document.createElement("div");
+ actions.className = "audit-actions";
+ const pay = document.createElement("button");
+ pay.className = "button";
+ pay.type = "button";
+ pay.textContent = "Pay $5";
+ pay.addEventListener("click", () => startCheckout("single"));
+ const code = document.createElement("button");
+ code.className = "button secondary";
+ code.type = "button";
+ code.textContent = "Use access code";
+ code.addEventListener("click", () => showOperator("single"));
+ actions.append(pay, code);
+ gate.appendChild(actions);
out.appendChild(gate);
}
}
async function runAudit() {
- clear();
+ clearResults();
status.textContent = "Running audit…";
try {
const response = await fetch("/audit", {
@@ -237,32 +354,89 @@ hmmm
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Audit failed");
- renderResult(result, false);
+ renderPreview(result);
+ } catch (error) {
+ clearResults();
+ status.textContent = error.message;
+ }
+ }
+
+ async function startCheckout(tier) {
+ clearResults();
+ status.textContent = "Creating checkout…";
+ try {
+ const response = await fetch("/checkout", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({tier, repo_urls: repoUrlsFor(tier)})
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || "Checkout failed");
+ location.assign(result.checkout_url);
+ } catch (error) {
+ status.textContent = error.message;
+ }
+ }
+
+ function showOperator(tier) {
+ activeTier = tier;
+ operatorPanel.hidden = false;
+ operatorCode.focus();
+ operatorPanel.scrollIntoView({behavior: "smooth", block: "center"});
+ }
+
+ async function runOperator() {
+ clearResults();
+ status.textContent = "Running full audit…";
+ try {
+ const response = await fetch("/operator/audit", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ code: operatorCode.value,
+ repo_urls: repoUrlsFor(activeTier)
+ })
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || "Operator audit failed");
+ operatorCode.value = "";
+ operatorPanel.hidden = true;
+ renderBatch(result);
} catch (error) {
- clear();
status.textContent = error.message;
}
}
async function loadPaid(sessionId) {
selectProduct("audit");
- clear();
- status.textContent = "Verifying payment and running full audit…";
+ clearResults();
+ status.textContent = "Verifying payment and running audit…";
try {
const response = await fetch(`/paid?session_id=${encodeURIComponent(sessionId)}`);
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Paid audit failed");
- renderResult(result, true);
+ renderBatch(result);
history.replaceState({}, "", "/");
} catch (error) {
- clear();
+ clearResults();
status.textContent = error.message;
}
}
document.getElementById("choose-audit").addEventListener("click", () => selectProduct("audit"));
document.getElementById("choose-examiner").addEventListener("click", () => selectProduct("examiner"));
+ document.getElementById("choose-single").addEventListener("click", () => selectTier("single"));
+ document.getElementById("choose-bundle").addEventListener("click", () => selectTier("bundle5"));
document.getElementById("audit").addEventListener("click", runAudit);
+ document.getElementById("pay-single").addEventListener("click", () => startCheckout("single"));
+ document.getElementById("pay-bundle").addEventListener("click", () => startCheckout("bundle5"));
+ document.getElementById("code-single").addEventListener("click", () => showOperator("single"));
+ document.getElementById("code-bundle").addEventListener("click", () => showOperator("bundle5"));
+ document.getElementById("run-operator").addEventListener("click", runOperator);
+ document.getElementById("cancel-operator").addEventListener("click", () => {
+ operatorPanel.hidden = true;
+ operatorCode.value = "";
+ });
repo.addEventListener("keydown", event => {
if (event.key === "Enter") runAudit();
});
diff --git a/service.py b/service.py
index 80ddb11..65e76c6 100644
--- a/service.py
+++ b/service.py
@@ -1,11 +1,12 @@
import base64
+import hmac
import json
import os
import subprocess
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
-from urllib.parse import parse_qs, quote, urlsplit
+from urllib.parse import parse_qs, quote, urlencode, urlsplit
from urllib.request import Request, urlopen
from pubskill_lib.audit import audit_path
@@ -17,10 +18,23 @@
"codeberg.org",
"git.sr.ht",
}
-PAYMENT_LINK_ID = "plink_1UFlbvAyiOEDWiRnnsPOpA2P"
-PAYMENT_LINK_URL = "https://buy.stripe.com/bJe9AS94w9MW09B7705EY02"
-PRICE_CENTS = 500
+AUDIT_TIERS = {
+ "single": {
+ "repositories": 1,
+ "amount_cents": 500,
+ "price_id": "price_1UFlntAyiOEDWiRnVtvKTiUB",
+ },
+ "bundle5": {
+ "repositories": 5,
+ "amount_cents": 2000,
+ "price_id": "price_1UFlncAyiOEDWiRnJzM9ewHX",
+ },
+}
FREE_FINDINGS = 3
+DEFAULT_SUCCESS_URL = (
+ "https://pubskill.interdependentway.org/?session_id={CHECKOUT_SESSION_ID}"
+)
+DEFAULT_CANCEL_URL = "https://pubskill.interdependentway.org/"
def valid_repo_url(value):
@@ -44,6 +58,22 @@ def valid_repo_url(value):
)
+def validated_repo_urls(values, expected_count=None):
+ if not isinstance(values, list):
+ raise ValueError("repository URLs must be a list")
+
+ repo_urls = [str(value).strip() for value in values]
+ if expected_count is not None and len(repo_urls) != expected_count:
+ raise ValueError(f"exactly {expected_count} repository URL(s) required")
+ if len(repo_urls) not in (1, 5):
+ raise ValueError("audit purchases support 1 or 5 repositories")
+ if any(not valid_repo_url(value) for value in repo_urls):
+ raise ValueError("supported public Git repository required")
+ if len(set(repo_urls)) != len(repo_urls):
+ raise ValueError("duplicate repository URLs are not allowed")
+ return repo_urls
+
+
def run_audit(repo_url):
if not valid_repo_url(repo_url):
raise ValueError("supported public Git repository required")
@@ -60,35 +90,107 @@ def run_audit(repo_url):
return audit_path(target)
-def stripe_session(session_id):
+def run_audit_batch(repo_urls):
+ audits = []
+ for repo_url in repo_urls:
+ try:
+ result = run_audit(repo_url)
+ audits.append({"repo_url": repo_url, "result": result})
+ except subprocess.TimeoutExpired:
+ audits.append({"repo_url": repo_url, "error": "repository audit timed out"})
+ except subprocess.CalledProcessError:
+ audits.append({"repo_url": repo_url, "error": "repository could not be cloned"})
+ except Exception as exc:
+ print(f"audit failure for {repo_url!r}: {exc!r}", flush=True)
+ audits.append({"repo_url": repo_url, "error": "repository audit failed"})
+ return audits
+
+
+def stripe_api(path, form=None):
key = os.environ.get("STRIPE_SECRET_KEY")
if not key:
raise RuntimeError("payment verification is not configured")
auth = base64.b64encode(f"{key}:".encode()).decode()
+ headers = {"Authorization": f"Basic {auth}"}
+ data = None
+ if form is not None:
+ data = urlencode(form).encode()
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
+
request = Request(
- f"https://api.stripe.com/v1/checkout/sessions/{quote(session_id, safe='')}",
- headers={"Authorization": f"Basic {auth}"},
+ f"https://api.stripe.com/v1/{path.lstrip('/')}",
+ data=data,
+ headers=headers,
)
with urlopen(request, timeout=15) as response:
return json.load(response)
-def paid_repo(session):
- if session.get("payment_status") != "paid":
+def create_checkout(repo_urls, tier):
+ config = AUDIT_TIERS.get(tier)
+ if config is None:
+ raise ValueError("unknown audit purchase")
+ repo_urls = validated_repo_urls(repo_urls, config["repositories"])
+
+ success_url = os.environ.get("PUBSKILL_SUCCESS_URL", DEFAULT_SUCCESS_URL)
+ cancel_url = os.environ.get("PUBSKILL_CANCEL_URL", DEFAULT_CANCEL_URL)
+ form = [
+ ("mode", "payment"),
+ ("success_url", success_url),
+ ("cancel_url", cancel_url),
+ ("line_items[0][price]", config["price_id"]),
+ ("line_items[0][quantity]", "1"),
+ ("client_reference_id", "pubskill-audit"),
+ ("metadata[pubskill_product]", "audit"),
+ ("metadata[pubskill_tier]", tier),
+ ("metadata[repo_count]", str(config["repositories"])),
+ ]
+ for index, repo_url in enumerate(repo_urls, start=1):
+ form.append((f"metadata[repo_{index}]", repo_url))
+
+ session = stripe_api("checkout/sessions", form)
+ if not session.get("id") or not session.get("url"):
+ raise RuntimeError("checkout session did not return a payment URL")
+ return session
+
+
+def stripe_session(session_id):
+ return stripe_api(f"checkout/sessions/{quote(session_id, safe='')}")
+
+
+def paid_repos(session):
+ if session.get("payment_status") != "paid" or session.get("status") != "complete":
raise PermissionError("payment is not complete")
- if session.get("payment_link") != PAYMENT_LINK_ID:
+ if session.get("mode") != "payment":
+ raise PermissionError("checkout session is not a payment")
+ if not session.get("livemode"):
+ raise PermissionError("checkout session is not live")
+
+ metadata = session.get("metadata") or {}
+ if metadata.get("pubskill_product") != "audit":
raise PermissionError("payment does not belong to this product")
- if session.get("currency") != "usd" or session.get("amount_total") != PRICE_CENTS:
+
+ tier = metadata.get("pubskill_tier")
+ config = AUDIT_TIERS.get(tier)
+ if config is None:
+ raise PermissionError("payment tier is not recognized")
+ if session.get("currency") != "usd" or session.get("amount_total") != config["amount_cents"]:
raise PermissionError("payment amount does not match this product")
+ if metadata.get("repo_count") != str(config["repositories"]):
+ raise PermissionError("payment repository count does not match this product")
+
+ repo_urls = [
+ (metadata.get(f"repo_{index}") or "").strip()
+ for index in range(1, config["repositories"] + 1)
+ ]
+ return tier, validated_repo_urls(repo_urls, config["repositories"])
- for field in session.get("custom_fields") or []:
- if field.get("key") == "githubrepo" and field.get("type") == "text":
- repo_url = (field.get("text") or {}).get("value", "").strip()
- if valid_repo_url(repo_url):
- return repo_url
- raise ValueError("paid checkout is missing a valid supported Git repository URL")
+def operator_authorized(code):
+ expected = os.environ.get("PUBSKILL_OPERATOR_CODE", "")
+ supplied = str(code or "")
+ return bool(expected) and hmac.compare_digest(supplied, expected)
class Handler(BaseHTTPRequestHandler):
@@ -110,6 +212,12 @@ def page(self):
self.end_headers()
self.wfile.write(data)
+ def request_json(self):
+ length = int(self.headers.get("Content-Length", 0))
+ if length <= 0 or length > 16384:
+ raise ValueError("invalid request")
+ return json.loads(self.rfile.read(length))
+
def do_GET(self):
parsed = urlsplit(self.path)
if parsed.path == "/":
@@ -121,10 +229,16 @@ def do_GET(self):
return self.reply(400, {"error": "missing checkout session"})
try:
session = stripe_session(session_id)
- repo_url = paid_repo(session)
- result = run_audit(repo_url)
- result["paid"] = True
- return self.reply(200, result)
+ tier, repo_urls = paid_repos(session)
+ return self.reply(
+ 200,
+ {
+ "paid": True,
+ "tier": tier,
+ "repository_count": len(repo_urls),
+ "audits": run_audit_batch(repo_urls),
+ },
+ )
except PermissionError as exc:
return self.reply(402, {"error": str(exc)})
except ValueError as exc:
@@ -136,38 +250,78 @@ def do_GET(self):
return self.reply(404, {"error": "not found"})
def do_POST(self):
- if urlsplit(self.path).path != "/audit":
- return self.reply(404, {"error": "not found"})
+ path = urlsplit(self.path).path
- try:
- length = int(self.headers.get("Content-Length", 0))
- if length <= 0 or length > 8192:
- return self.reply(400, {"error": "invalid request"})
- body = json.loads(self.rfile.read(length))
- repo_url = str(body["repo_url"]).strip()
- result = run_audit(repo_url)
+ if path == "/audit":
+ try:
+ body = self.request_json()
+ repo_url = str(body["repo_url"]).strip()
+ result = run_audit(repo_url)
+ findings = result.get("findings") or []
+ preview = {
+ "target": result.get("target"),
+ "surfaces": result.get("surfaces"),
+ "findings": findings[:FREE_FINDINGS],
+ "total_findings": len(findings),
+ "hmmm_count": len(result.get("hmmm") or []),
+ "locked": len(findings) > FREE_FINDINGS or bool(result.get("hmmm")),
+ }
+ return self.reply(200, preview)
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
+ return self.reply(400, {"error": str(exc) or "invalid request"})
+ except subprocess.TimeoutExpired:
+ return self.reply(504, {"error": "repository audit timed out"})
+ except subprocess.CalledProcessError:
+ return self.reply(400, {"error": "repository could not be cloned"})
+ except Exception as exc:
+ print(f"audit failure: {exc!r}", flush=True)
+ return self.reply(500, {"error": "audit failed"})
- findings = result.get("findings") or []
- preview = {
- "target": result.get("target"),
- "surfaces": result.get("surfaces"),
- "findings": findings[:FREE_FINDINGS],
- "total_findings": len(findings),
- "hmmm_count": len(result.get("hmmm") or []),
- "locked": len(findings) > FREE_FINDINGS or bool(result.get("hmmm")),
- "payment_link": PAYMENT_LINK_URL,
- }
- return self.reply(200, preview)
-
- except (ValueError, KeyError, json.JSONDecodeError) as exc:
- return self.reply(400, {"error": str(exc) or "invalid request"})
- except subprocess.TimeoutExpired:
- return self.reply(504, {"error": "repository audit timed out"})
- except subprocess.CalledProcessError:
- return self.reply(400, {"error": "repository could not be cloned"})
- except Exception as exc:
- print(f"audit failure: {exc!r}", flush=True)
- return self.reply(500, {"error": "audit failed"})
+ if path == "/checkout":
+ try:
+ body = self.request_json()
+ tier = str(body["tier"]).strip()
+ config = AUDIT_TIERS.get(tier)
+ if config is None:
+ raise ValueError("unknown audit purchase")
+ repo_urls = validated_repo_urls(body["repo_urls"], config["repositories"])
+ session = create_checkout(repo_urls, tier)
+ return self.reply(
+ 200,
+ {
+ "checkout_url": session["url"],
+ "session_id": session["id"],
+ },
+ )
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
+ return self.reply(400, {"error": str(exc) or "invalid request"})
+ except Exception as exc:
+ print(f"checkout failure: {exc!r}", flush=True)
+ return self.reply(500, {"error": "checkout could not be created"})
+
+ if path == "/operator/audit":
+ try:
+ body = self.request_json()
+ if not operator_authorized(body.get("code")):
+ return self.reply(403, {"error": "access code not accepted"})
+ repo_urls = validated_repo_urls(body["repo_urls"])
+ tier = "single" if len(repo_urls) == 1 else "bundle5"
+ return self.reply(
+ 200,
+ {
+ "operator": True,
+ "tier": tier,
+ "repository_count": len(repo_urls),
+ "audits": run_audit_batch(repo_urls),
+ },
+ )
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
+ return self.reply(400, {"error": str(exc) or "invalid request"})
+ except Exception as exc:
+ print(f"operator audit failure: {exc!r}", flush=True)
+ return self.reply(500, {"error": "operator audit failed"})
+
+ return self.reply(404, {"error": "not found"})
def main():
diff --git a/tests/test_service.py b/tests/test_service.py
index 751902d..8f0e711 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -1,6 +1,8 @@
from __future__ import annotations
+import os
import unittest
+from unittest.mock import patch
import service
@@ -35,54 +37,123 @@ def test_repo_url_allowlist_rejects_credential_and_routing_escapes(self) -> None
with self.subTest(value=value):
self.assertFalse(service.valid_repo_url(value))
- def test_paid_repo_binds_payment_product_amount_and_repository(self) -> None:
+ def test_purchase_repo_counts_are_exact_and_unique(self) -> None:
+ one = ["https://github.com/owner/repo"]
+ five = [f"https://github.com/owner/repo-{index}" for index in range(5)]
+ self.assertEqual(service.validated_repo_urls(one, 1), one)
+ self.assertEqual(service.validated_repo_urls(five, 5), five)
+
+ with self.assertRaises(ValueError):
+ service.validated_repo_urls(one, 5)
+ with self.assertRaises(ValueError):
+ service.validated_repo_urls(one * 5, 5)
+ with self.assertRaises(ValueError):
+ service.validated_repo_urls(
+ ["https://github.com/owner/a", "https://github.com/owner/b"]
+ )
+
+ def test_paid_repos_binds_live_session_tier_amount_and_repositories(self) -> None:
session = {
"payment_status": "paid",
- "payment_link": service.PAYMENT_LINK_ID,
+ "status": "complete",
+ "mode": "payment",
+ "livemode": True,
"currency": "usd",
- "amount_total": service.PRICE_CENTS,
- "custom_fields": [
- {
- "key": "githubrepo",
- "type": "text",
- "text": {"value": "https://codeberg.org/owner/repo"},
- }
- ],
+ "amount_total": 500,
+ "metadata": {
+ "pubskill_product": "audit",
+ "pubskill_tier": "single",
+ "repo_count": "1",
+ "repo_1": "https://codeberg.org/owner/repo",
+ },
}
self.assertEqual(
- service.paid_repo(session),
- "https://codeberg.org/owner/repo",
+ service.paid_repos(session),
+ ("single", ["https://codeberg.org/owner/repo"]),
)
for field, value in (
("payment_status", "unpaid"),
- ("payment_link", "plink_other"),
+ ("status", "open"),
+ ("mode", "setup"),
+ ("livemode", False),
("currency", "eur"),
- ("amount_total", service.PRICE_CENTS + 1),
+ ("amount_total", 501),
):
altered = dict(session)
altered[field] = value
with self.subTest(field=field):
with self.assertRaises(PermissionError):
- service.paid_repo(altered)
+ service.paid_repos(altered)
- def test_paid_repo_rejects_missing_or_invalid_checkout_repository(self) -> None:
+ def test_paid_repos_rejects_foreign_or_malformed_metadata(self) -> None:
base = {
"payment_status": "paid",
- "payment_link": service.PAYMENT_LINK_ID,
+ "status": "complete",
+ "mode": "payment",
+ "livemode": True,
"currency": "usd",
- "amount_total": service.PRICE_CENTS,
+ "amount_total": 2000,
}
- for custom_fields in (
- [],
- [{"key": "other", "type": "text", "text": {"value": "https://github.com/a/b"}}],
- [{"key": "githubrepo", "type": "text", "text": {"value": "https://example.com/a/b"}}],
- ):
+ bad_metadata = (
+ {},
+ {
+ "pubskill_product": "other",
+ "pubskill_tier": "bundle5",
+ "repo_count": "5",
+ },
+ {
+ "pubskill_product": "audit",
+ "pubskill_tier": "bundle5",
+ "repo_count": "4",
+ },
+ {
+ "pubskill_product": "audit",
+ "pubskill_tier": "bundle5",
+ "repo_count": "5",
+ "repo_1": "https://example.com/a/b",
+ "repo_2": "https://github.com/a/b",
+ "repo_3": "https://github.com/c/d",
+ "repo_4": "https://github.com/e/f",
+ "repo_5": "https://github.com/g/h",
+ },
+ )
+ for metadata in bad_metadata:
session = dict(base)
- session["custom_fields"] = custom_fields
- with self.subTest(custom_fields=custom_fields):
- with self.assertRaises(ValueError):
- service.paid_repo(session)
+ session["metadata"] = metadata
+ with self.subTest(metadata=metadata):
+ with self.assertRaises((PermissionError, ValueError)):
+ service.paid_repos(session)
+
+ def test_operator_code_is_server_side_and_constant_time_comparable(self) -> None:
+ with patch.dict(os.environ, {"PUBSKILL_OPERATOR_CODE": "secret-code"}, clear=False):
+ self.assertTrue(service.operator_authorized("secret-code"))
+ self.assertFalse(service.operator_authorized("wrong"))
+ with patch.dict(os.environ, {}, clear=True):
+ self.assertFalse(service.operator_authorized("secret-code"))
+
+ def test_checkout_form_binds_exact_repository_metadata(self) -> None:
+ captured = {}
+
+ def fake_stripe_api(path, form=None):
+ captured["path"] = path
+ captured["form"] = dict(form or [])
+ return {"id": "cs_test", "url": "https://checkout.stripe.com/test"}
+
+ repos = [f"https://github.com/owner/repo-{index}" for index in range(5)]
+ with patch.object(service, "stripe_api", fake_stripe_api):
+ session = service.create_checkout(repos, "bundle5")
+
+ self.assertEqual(session["id"], "cs_test")
+ self.assertEqual(captured["path"], "checkout/sessions")
+ self.assertEqual(
+ captured["form"]["line_items[0][price]"],
+ service.AUDIT_TIERS["bundle5"]["price_id"],
+ )
+ self.assertEqual(captured["form"]["metadata[pubskill_tier]"], "bundle5")
+ self.assertEqual(captured["form"]["metadata[repo_count]"], "5")
+ for index, repo in enumerate(repos, start=1):
+ self.assertEqual(captured["form"][f"metadata[repo_{index}]"], repo)
if __name__ == "__main__":