From 3d4dad60f44003e284b381d9174c4f08f506dcd2 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 14 Sep 2026 19:11:22 -0700 Subject: [PATCH 1/4] feat(service): add 1-or-5 checkout and operator audit access --- service.py | 256 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 205 insertions(+), 51 deletions(-) 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(): From 5bc86dd2ed7e9c3590b815312913f2ef9b58f8eb Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 14 Sep 2026 19:11:53 -0700 Subject: [PATCH 2/4] test(service): cover bundle checkout and operator access --- tests/test_service.py | 125 +++++++++++++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 27 deletions(-) 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__": From a8d862ed30a3a4d81e71c68592bb04173fef44eb Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 14 Sep 2026 19:13:21 -0700 Subject: [PATCH 3/4] feat(site): add audit bundles and operator access path --- index.html | 264 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 219 insertions(+), 45 deletions(-) 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

+
+ + +
+
+
+
+ + + +
+ +
@@ -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(); }); From bbe6dba23fb24ac0db9c5a8775b0738bc58b3e25 Mon Sep 17 00:00:00 2001 From: Erin Spencer Date: Mon, 14 Sep 2026 19:13:34 -0700 Subject: [PATCH 4/4] docs: bind hosted audit purchase and operator contract --- HOSTED.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 HOSTED.md 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.