diff --git a/HOSTED.md b/HOSTED.md
index 684efbf..ced75f8 100644
--- a/HOSTED.md
+++ b/HOSTED.md
@@ -7,12 +7,19 @@ The hosted surface at `https://pubskill.interdependentway.org/` preserves two pr
## Audit purchases
-The hosted audit supports two purchase sizes:
+A complete repository audit costs **$5 USD**. Customers may purchase any positive number of audits in one checkout. For every group of five audits, the fifth is free:
-- one complete repository audit: **$5 USD**
-- five complete repository audits: **$20 USD**
+- 1 audit: $5
+- 4 audits: $20
+- 5 audits: $20
+- 6 audits: $25
+- 10 audits: $40
-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.
+The pricing rule is `paid_count = repository_count - floor(repository_count / 5)`.
+
+A single repository may first receive a three-finding free preview. For paid audits, repository URLs are supplied before checkout. The service stores the repository count and a SHA-256 digest of the ordered URL list in Stripe Checkout Session metadata, rather than storing an arbitrary number of repository URLs in Stripe metadata. The Checkout line-item quantity equals the number of paid audits after applying the every-fifth-free rule.
+
+On return from Stripe, the browser resubmits the repository list. The service verifies live payment state, amount, paid/free counts, repository count, and the URL-list digest before running the complete audits. The browser keeps the pending list only in session storage during checkout. If that state is lost, the service exposes the paid repository count so the customer can re-enter the same URLs and recover the purchase.
Supported public HTTPS Git hosts are GitHub, GitLab, Bitbucket, Codeberg, and SourceHut. Target repository code is not executed.
@@ -20,7 +27,7 @@ Supported public HTTPS Git hosts are GitHub, GitLab, Bitbucket, Codeberg, and So
`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.
+Operator access runs the same complete audit path for any positive number of repository URLs without creating a Stripe checkout.
## Deployment configuration
diff --git a/index.html b/index.html
index e6f0a8c..3585238 100644
--- a/index.html
+++ b/index.html
@@ -20,12 +20,13 @@
.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], .tier-panel[hidden], .operator-panel[hidden] { display: none; }
+ .product-panel[hidden], .quantity-panel[hidden], .operator-panel[hidden], .recovery-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: .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; }
+ .audit-panel label, .operator-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, .count-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; }
+ .repo-field:focus, .code-field:focus, .count-field:focus { border-color: var(--violet); outline: 3px solid rgba(155,135,245,.18); outline-offset: 2px; }
+ .count-field { max-width: 9rem; }
.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); }
@@ -39,10 +40,13 @@
.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; }
+ .quantity-choices .card { min-height: 11rem; }
+ .quantity-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; }
+ .price-line { color: var(--cyan); font-weight: 700; margin: .8rem 0; }
+ .promo-line { color: var(--silver); }
+ .repo-fields { display: grid; gap: .35rem; margin-top: .8rem; }
.site-footer a { color: var(--silver); }
@media (max-width: 780px) {
.pubskill-head h1 { font-size: clamp(2.55rem, 13vw, 4.2rem); }
@@ -79,7 +83,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. Full audits can be purchased for one repository or five at a time.
+ Three findings are free for one repository. Complete audits are $5 each; every fifth audit is free.
@@ -96,6 +100,7 @@ Repository Examiner
Audit
Which repository claims hold up, and which fail against the evidence?
+
$5 per complete audit. Buy four and the fifth is free; the rule repeats for every five.
GitHub
GitLab
@@ -105,22 +110,22 @@
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.
+ Run the 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.
-
+
+ Multiple repositories
+ Choose any quantity
+ Every fifth audit is free. Five costs $20, ten costs $40, and the same rule continues.
+
-
+
-
+
-
Five repositories · $20
-
+
+
+
+
-
-
+
+
@@ -160,6 +167,13 @@ $20 bundle
+
+ Paid audit recovery
+
+
+
+
+
@@ -209,25 +223,61 @@ hmmm
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 multipleChoice = document.getElementById("multiple-choice");
const singlePanel = document.getElementById("single-panel");
- const bundlePanel = document.getElementById("bundle-panel");
+ const multiplePanel = document.getElementById("multiple-panel");
+ const auditCount = document.getElementById("audit-count");
+ const multipleFields = document.getElementById("multiple-fields");
+ const priceSummary = document.getElementById("price-summary");
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);
+ const recoveryPanel = document.getElementById("recovery-panel");
+ const recoveryFields = document.getElementById("recovery-fields");
+ const recoveryCopy = document.getElementById("recovery-copy");
+ let activeQuantityMode = "single";
+ let paidRecoverySession = "";
+
+ function pricingFor(count) {
+ const free = Math.floor(count / 5);
+ const paid = count - free;
+ return {count, free, paid, dollars: paid * 5};
+ }
+
+ function validCount() {
+ const value = Number(auditCount.value);
+ if (!Number.isInteger(value) || value < 2) throw new Error("Choose at least 2 repositories.");
+ return value;
+ }
+
+ function buildRepoFields(container, count, className, existing = []) {
+ const values = existing.length ? existing : [...container.querySelectorAll("input")].map(input => input.value);
+ container.replaceChildren();
+ for (let index = 1; index <= count; index += 1) {
+ const label = document.createElement("label");
+ const id = `${className}-${index}`;
+ label.htmlFor = id;
+ label.textContent = `Repository ${index}`;
+ const input = document.createElement("input");
+ input.className = `repo-field ${className}`;
+ input.id = id;
+ input.inputMode = "url";
+ input.autocomplete = "url";
+ input.placeholder = `https://github.com/owner/repo-${index}.git`;
+ input.value = values[index - 1] || "";
+ container.append(label, input);
+ }
+ }
+
+ function updateMultipleFields() {
+ try {
+ const count = validCount();
+ buildRepoFields(multipleFields, count, "multiple-repo");
+ const price = pricingFor(count);
+ priceSummary.textContent = `${count} complete audits · ${price.free} free · $${price.dollars} total`;
+ document.getElementById("pay-multiple").textContent = `Pay $${price.dollars}`;
+ } catch (error) {
+ priceSummary.textContent = error.message;
+ }
}
function selectProduct(product) {
@@ -239,22 +289,24 @@ hmmm
(auditSelected ? auditPanel : examinerPanel).scrollIntoView({behavior: "smooth", block: "start"});
}
- function selectTier(tier) {
- activeTier = tier;
- const single = tier === "single";
+ function selectQuantityMode(mode) {
+ activeQuantityMode = mode;
+ const single = mode === "single";
singlePanel.hidden = !single;
- bundlePanel.hidden = single;
+ multiplePanel.hidden = single;
singleChoice.setAttribute("aria-current", String(single));
- bundleChoice.setAttribute("aria-current", String(!single));
+ multipleChoice.setAttribute("aria-current", String(!single));
operatorPanel.hidden = true;
+ recoveryPanel.hidden = true;
operatorCode.value = "";
status.textContent = "";
out.replaceChildren();
+ if (!single) updateMultipleFields();
}
- function repoUrlsFor(tier) {
- if (tier === "single") return [repo.value.trim()];
- return [...document.querySelectorAll(".bundle-repo")].map(input => input.value.trim());
+ function repoUrlsForMode(mode = activeQuantityMode) {
+ if (mode === "single") return [repo.value.trim()];
+ return [...document.querySelectorAll(".multiple-repo")].map(input => input.value.trim());
}
function add(tag, text, parent = out, className = "") {
@@ -300,6 +352,9 @@ hmmm
clearResults();
add("p", payload.operator ? "operator audit" : "paid audit", out, "eyebrow");
add("h2", `${payload.repository_count} complete audit${payload.repository_count === 1 ? "" : "s"}`);
+ if (!payload.operator && payload.free_count) {
+ add("p", `${payload.free_count} audit${payload.free_count === 1 ? "" : "s"} free under the every-fifth-free rule.`, out, "meta");
+ }
for (const item of payload.audits || []) {
const section = document.createElement("section");
section.className = "panel batch-result";
@@ -331,7 +386,7 @@ hmmm
pay.className = "button";
pay.type = "button";
pay.textContent = "Pay $5";
- pay.addEventListener("click", () => startCheckout("single"));
+ pay.addEventListener("click", () => startCheckout([repo.value.trim()]));
const code = document.createElement("button");
code.className = "button secondary";
code.type = "button";
@@ -361,25 +416,26 @@ hmmm
}
}
- async function startCheckout(tier) {
+ async function startCheckout(repoUrls) {
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)})
+ body: JSON.stringify({repo_urls: repoUrls})
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "Checkout failed");
+ sessionStorage.setItem("pubskill_pending_repos", JSON.stringify(repoUrls));
location.assign(result.checkout_url);
} catch (error) {
status.textContent = error.message;
}
}
- function showOperator(tier) {
- activeTier = tier;
+ function showOperator(mode) {
+ activeQuantityMode = mode;
operatorPanel.hidden = false;
operatorCode.focus();
operatorPanel.scrollIntoView({behavior: "smooth", block: "center"});
@@ -394,7 +450,7 @@ hmmm
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
code: operatorCode.value,
- repo_urls: repoUrlsFor(activeTier)
+ repo_urls: repoUrlsForMode()
})
});
const result = await response.json();
@@ -407,40 +463,86 @@ hmmm
}
}
+ async function submitPaid(sessionId, repoUrls) {
+ const response = await fetch("/paid", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({session_id: sessionId, repo_urls: repoUrls})
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || "Paid audit failed");
+ sessionStorage.removeItem("pubskill_pending_repos");
+ renderBatch(result);
+ history.replaceState({}, "", "/");
+ }
+
async function loadPaid(sessionId) {
selectProduct("audit");
clearResults();
- status.textContent = "Verifying payment and running audit…";
+ status.textContent = "Verifying payment…";
+ let stored = null;
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");
- renderBatch(result);
- history.replaceState({}, "", "/");
+ stored = JSON.parse(sessionStorage.getItem("pubskill_pending_repos") || "null");
+ } catch (_) {
+ stored = null;
+ }
+ if (Array.isArray(stored) && stored.length) {
+ try {
+ status.textContent = "Verifying payment and running audits…";
+ await submitPaid(sessionId, stored);
+ return;
+ } catch (error) {
+ status.textContent = error.message;
+ return;
+ }
+ }
+
+ try {
+ const response = await fetch(`/paid-info?session_id=${encodeURIComponent(sessionId)}`);
+ const info = await response.json();
+ if (!response.ok) throw new Error(info.error || "Paid audit could not be recovered");
+ paidRecoverySession = sessionId;
+ recoveryPanel.hidden = false;
+ recoveryCopy.textContent = `Payment covers ${info.repository_count} repository audit${info.repository_count === 1 ? "" : "s"}. Re-enter the same repository URL${info.repository_count === 1 ? "" : "s"} to verify the purchase binding.`;
+ buildRepoFields(recoveryFields, info.repository_count, "recovery-repo", []);
+ status.textContent = "Payment verified. Repository list needs to be restored.";
+ } catch (error) {
+ status.textContent = error.message;
+ }
+ }
+
+ async function recoverPaid() {
+ const repoUrls = [...document.querySelectorAll(".recovery-repo")].map(input => input.value.trim());
+ try {
+ status.textContent = "Verifying repository list and running audits…";
+ await submitPaid(paidRecoverySession, repoUrls);
+ recoveryPanel.hidden = true;
} catch (error) {
- 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("choose-single").addEventListener("click", () => selectQuantityMode("single"));
+ document.getElementById("choose-multiple").addEventListener("click", () => selectQuantityMode("multiple"));
document.getElementById("audit").addEventListener("click", runAudit);
- document.getElementById("pay-single").addEventListener("click", () => startCheckout("single"));
- document.getElementById("pay-bundle").addEventListener("click", () => startCheckout("bundle5"));
+ document.getElementById("pay-single").addEventListener("click", () => startCheckout([repo.value.trim()]));
+ document.getElementById("pay-multiple").addEventListener("click", () => startCheckout(repoUrlsForMode("multiple")));
document.getElementById("code-single").addEventListener("click", () => showOperator("single"));
- document.getElementById("code-bundle").addEventListener("click", () => showOperator("bundle5"));
+ document.getElementById("code-multiple").addEventListener("click", () => showOperator("multiple"));
document.getElementById("run-operator").addEventListener("click", runOperator);
document.getElementById("cancel-operator").addEventListener("click", () => {
operatorPanel.hidden = true;
operatorCode.value = "";
});
+ document.getElementById("recover-paid").addEventListener("click", recoverPaid);
+ auditCount.addEventListener("input", updateMultipleFields);
repo.addEventListener("keydown", event => {
if (event.key === "Enter") runAudit();
});
+ updateMultipleFields();
const sessionId = new URLSearchParams(location.search).get("session_id");
if (sessionId) loadPaid(sessionId);
diff --git a/service.py b/service.py
index 65e76c6..b9d2314 100644
--- a/service.py
+++ b/service.py
@@ -1,4 +1,5 @@
import base64
+import hashlib
import hmac
import json
import os
@@ -18,19 +19,10 @@
"codeberg.org",
"git.sr.ht",
}
-AUDIT_TIERS = {
- "single": {
- "repositories": 1,
- "amount_cents": 500,
- "price_id": "price_1UFlntAyiOEDWiRnVtvKTiUB",
- },
- "bundle5": {
- "repositories": 5,
- "amount_cents": 2000,
- "price_id": "price_1UFlncAyiOEDWiRnJzM9ewHX",
- },
-}
+AUDIT_UNIT_PRICE_ID = "price_1UFlntAyiOEDWiRnVtvKTiUB"
+AUDIT_UNIT_CENTS = 500
FREE_FINDINGS = 3
+MAX_REQUEST_BYTES = 1024 * 1024
DEFAULT_SUCCESS_URL = (
"https://pubskill.interdependentway.org/?session_id={CHECKOUT_SESSION_ID}"
)
@@ -63,10 +55,10 @@ def validated_repo_urls(values, expected_count=None):
raise ValueError("repository URLs must be a list")
repo_urls = [str(value).strip() for value in values]
+ if not repo_urls:
+ raise ValueError("at least one repository URL is required")
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):
@@ -74,6 +66,24 @@ def validated_repo_urls(values, expected_count=None):
return repo_urls
+def audit_pricing(repository_count):
+ if type(repository_count) is not int or repository_count < 1:
+ raise ValueError("repository count must be a positive integer")
+ free_count = repository_count // 5
+ paid_count = repository_count - free_count
+ return {
+ "repository_count": repository_count,
+ "free_count": free_count,
+ "paid_count": paid_count,
+ "amount_cents": paid_count * AUDIT_UNIT_CENTS,
+ }
+
+
+def repo_digest(repo_urls):
+ payload = json.dumps(repo_urls, ensure_ascii=False, separators=(",", ":")).encode()
+ return hashlib.sha256(payload).hexdigest()
+
+
def run_audit(repo_url):
if not valid_repo_url(repo_url):
raise ValueError("supported public Git repository required")
@@ -127,11 +137,9 @@ def stripe_api(path, form=None):
return json.load(response)
-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"])
+def create_checkout(repo_urls):
+ repo_urls = validated_repo_urls(repo_urls)
+ pricing = audit_pricing(len(repo_urls))
success_url = os.environ.get("PUBSKILL_SUCCESS_URL", DEFAULT_SUCCESS_URL)
cancel_url = os.environ.get("PUBSKILL_CANCEL_URL", DEFAULT_CANCEL_URL)
@@ -139,27 +147,28 @@ def create_checkout(repo_urls, tier):
("mode", "payment"),
("success_url", success_url),
("cancel_url", cancel_url),
- ("line_items[0][price]", config["price_id"]),
- ("line_items[0][quantity]", "1"),
+ ("line_items[0][price]", AUDIT_UNIT_PRICE_ID),
+ ("line_items[0][quantity]", str(pricing["paid_count"])),
("client_reference_id", "pubskill-audit"),
("metadata[pubskill_product]", "audit"),
- ("metadata[pubskill_tier]", tier),
- ("metadata[repo_count]", str(config["repositories"])),
+ ("metadata[pricing_rule]", "every_fifth_free"),
+ ("metadata[repo_count]", str(pricing["repository_count"])),
+ ("metadata[paid_count]", str(pricing["paid_count"])),
+ ("metadata[free_count]", str(pricing["free_count"])),
+ ("metadata[repo_digest]", repo_digest(repo_urls)),
]
- 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
+ return session, pricing
def stripe_session(session_id):
return stripe_api(f"checkout/sessions/{quote(session_id, safe='')}")
-def paid_repos(session):
+def paid_order(session):
if session.get("payment_status") != "paid" or session.get("status") != "complete":
raise PermissionError("payment is not complete")
if session.get("mode") != "payment":
@@ -170,21 +179,34 @@ def paid_repos(session):
metadata = session.get("metadata") or {}
if metadata.get("pubskill_product") != "audit":
raise PermissionError("payment does not belong to this product")
+ if metadata.get("pricing_rule") != "every_fifth_free":
+ raise PermissionError("payment pricing rule is not recognized")
+
+ try:
+ repository_count = int(metadata.get("repo_count", ""))
+ except ValueError as exc:
+ raise PermissionError("payment repository count is invalid") from exc
+ pricing = audit_pricing(repository_count)
+
+ if metadata.get("paid_count") != str(pricing["paid_count"]):
+ raise PermissionError("payment paid-audit count does not match")
+ if metadata.get("free_count") != str(pricing["free_count"]):
+ raise PermissionError("payment free-audit count does not match")
+ if session.get("currency") != "usd" or session.get("amount_total") != pricing["amount_cents"]:
+ raise PermissionError("payment amount does not match this purchase")
+
+ digest = str(metadata.get("repo_digest") or "")
+ if len(digest) != 64:
+ raise PermissionError("payment repository binding is missing")
+ return pricing, digest
- 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"])
+
+def paid_repos(session, repo_urls):
+ pricing, expected_digest = paid_order(session)
+ repo_urls = validated_repo_urls(repo_urls, pricing["repository_count"])
+ if not hmac.compare_digest(repo_digest(repo_urls), expected_digest):
+ raise PermissionError("repository list does not match this purchase")
+ return pricing, repo_urls
def operator_authorized(code):
@@ -214,7 +236,7 @@ def page(self):
def request_json(self):
length = int(self.headers.get("Content-Length", 0))
- if length <= 0 or length > 16384:
+ if length <= 0 or length > MAX_REQUEST_BYTES:
raise ValueError("invalid request")
return json.loads(self.rfile.read(length))
@@ -223,29 +245,18 @@ def do_GET(self):
if parsed.path == "/":
return self.page()
- if parsed.path == "/paid":
+ if parsed.path == "/paid-info":
session_id = (parse_qs(parsed.query).get("session_id") or [""])[0]
if not session_id:
return self.reply(400, {"error": "missing checkout session"})
try:
- session = stripe_session(session_id)
- 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),
- },
- )
+ pricing, _ = paid_order(stripe_session(session_id))
+ return self.reply(200, pricing)
except PermissionError as exc:
return self.reply(402, {"error": str(exc)})
- except ValueError as exc:
- return self.reply(400, {"error": str(exc)})
except Exception as exc:
- print(f"paid audit failure: {exc!r}", flush=True)
- return self.reply(500, {"error": "paid audit could not be verified or completed"})
+ print(f"paid info failure: {exc!r}", flush=True)
+ return self.reply(500, {"error": "paid audit could not be verified"})
return self.reply(404, {"error": "not found"})
@@ -280,17 +291,14 @@ def do_POST(self):
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)
+ repo_urls = validated_repo_urls(body["repo_urls"])
+ session, pricing = create_checkout(repo_urls)
return self.reply(
200,
{
"checkout_url": session["url"],
"session_id": session["id"],
+ **pricing,
},
)
except (ValueError, KeyError, json.JSONDecodeError) as exc:
@@ -299,18 +307,42 @@ def do_POST(self):
print(f"checkout failure: {exc!r}", flush=True)
return self.reply(500, {"error": "checkout could not be created"})
+ if path == "/paid":
+ try:
+ body = self.request_json()
+ session_id = str(body["session_id"]).strip()
+ if not session_id:
+ raise ValueError("missing checkout session")
+ pricing, repo_urls = paid_repos(
+ stripe_session(session_id),
+ body["repo_urls"],
+ )
+ return self.reply(
+ 200,
+ {
+ "paid": True,
+ **pricing,
+ "audits": run_audit_batch(repo_urls),
+ },
+ )
+ except PermissionError as exc:
+ return self.reply(402, {"error": str(exc)})
+ except (ValueError, KeyError, json.JSONDecodeError) as exc:
+ return self.reply(400, {"error": str(exc) or "invalid request"})
+ except Exception as exc:
+ print(f"paid audit failure: {exc!r}", flush=True)
+ return self.reply(500, {"error": "paid audit could not be verified or completed"})
+
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),
},
diff --git a/tests/test_service.py b/tests/test_service.py
index 8f0e711..95b1d0c 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -37,40 +37,74 @@ 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_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)
+ def test_purchase_repo_counts_are_arbitrary_positive_and_unique(self) -> None:
+ for count in (1, 2, 4, 5, 6, 10, 17):
+ repos = [f"https://github.com/owner/repo-{index}" for index in range(count)]
+ with self.subTest(count=count):
+ self.assertEqual(service.validated_repo_urls(repos), repos)
+ self.assertEqual(service.validated_repo_urls(repos, count), repos)
with self.assertRaises(ValueError):
- service.validated_repo_urls(one, 5)
+ service.validated_repo_urls([])
with self.assertRaises(ValueError):
- service.validated_repo_urls(one * 5, 5)
+ service.validated_repo_urls(["https://github.com/owner/a"], 2)
with self.assertRaises(ValueError):
service.validated_repo_urls(
- ["https://github.com/owner/a", "https://github.com/owner/b"]
+ ["https://github.com/owner/a", "https://github.com/owner/a"]
)
- def test_paid_repos_binds_live_session_tier_amount_and_repositories(self) -> None:
+ def test_every_fifth_audit_is_free(self) -> None:
+ expected = {
+ 1: (1, 0, 500),
+ 4: (4, 0, 2000),
+ 5: (4, 1, 2000),
+ 6: (5, 1, 2500),
+ 9: (8, 1, 4000),
+ 10: (8, 2, 4000),
+ 11: (9, 2, 4500),
+ 25: (20, 5, 10000),
+ }
+ for count, (paid, free, amount) in expected.items():
+ with self.subTest(count=count):
+ pricing = service.audit_pricing(count)
+ self.assertEqual(pricing["repository_count"], count)
+ self.assertEqual(pricing["paid_count"], paid)
+ self.assertEqual(pricing["free_count"], free)
+ self.assertEqual(pricing["amount_cents"], amount)
+
+ for invalid in (0, -1, 1.5, True, "5"):
+ with self.subTest(invalid=invalid):
+ with self.assertRaises(ValueError):
+ service.audit_pricing(invalid)
+
+ def test_repo_digest_is_order_bound(self) -> None:
+ a = ["https://github.com/owner/a", "https://github.com/owner/b"]
+ b = list(reversed(a))
+ self.assertEqual(service.repo_digest(a), service.repo_digest(list(a)))
+ self.assertNotEqual(service.repo_digest(a), service.repo_digest(b))
+
+ def test_paid_repos_binds_live_session_price_count_and_repository_digest(self) -> None:
+ repos = [f"https://codeberg.org/owner/repo-{index}" for index in range(6)]
session = {
"payment_status": "paid",
"status": "complete",
"mode": "payment",
"livemode": True,
"currency": "usd",
- "amount_total": 500,
+ "amount_total": 2500,
"metadata": {
"pubskill_product": "audit",
- "pubskill_tier": "single",
- "repo_count": "1",
- "repo_1": "https://codeberg.org/owner/repo",
+ "pricing_rule": "every_fifth_free",
+ "repo_count": "6",
+ "paid_count": "5",
+ "free_count": "1",
+ "repo_digest": service.repo_digest(repos),
},
}
- self.assertEqual(
- service.paid_repos(session),
- ("single", ["https://codeberg.org/owner/repo"]),
- )
+ pricing, paid_repos = service.paid_repos(session, repos)
+ self.assertEqual(paid_repos, repos)
+ self.assertEqual(pricing["paid_count"], 5)
+ self.assertEqual(pricing["free_count"], 1)
for field, value in (
("payment_status", "unpaid"),
@@ -78,15 +112,20 @@ def test_paid_repos_binds_live_session_tier_amount_and_repositories(self) -> Non
("mode", "setup"),
("livemode", False),
("currency", "eur"),
- ("amount_total", 501),
+ ("amount_total", 2501),
):
altered = dict(session)
altered[field] = value
with self.subTest(field=field):
with self.assertRaises(PermissionError):
- service.paid_repos(altered)
+ service.paid_repos(altered, repos)
+
+ wrong_repos = list(repos)
+ wrong_repos[-1] = "https://codeberg.org/owner/different"
+ with self.assertRaises(PermissionError):
+ service.paid_repos(session, wrong_repos)
- def test_paid_repos_rejects_foreign_or_malformed_metadata(self) -> None:
+ def test_paid_order_rejects_malformed_metadata(self) -> None:
base = {
"payment_status": "paid",
"status": "complete",
@@ -99,23 +138,35 @@ def test_paid_repos_rejects_foreign_or_malformed_metadata(self) -> None:
{},
{
"pubskill_product": "other",
- "pubskill_tier": "bundle5",
+ "pricing_rule": "every_fifth_free",
"repo_count": "5",
+ "paid_count": "4",
+ "free_count": "1",
+ "repo_digest": "a" * 64,
},
{
"pubskill_product": "audit",
- "pubskill_tier": "bundle5",
- "repo_count": "4",
+ "pricing_rule": "other",
+ "repo_count": "5",
+ "paid_count": "4",
+ "free_count": "1",
+ "repo_digest": "a" * 64,
},
{
"pubskill_product": "audit",
- "pubskill_tier": "bundle5",
+ "pricing_rule": "every_fifth_free",
"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",
+ "paid_count": "5",
+ "free_count": "0",
+ "repo_digest": "a" * 64,
+ },
+ {
+ "pubskill_product": "audit",
+ "pricing_rule": "every_fifth_free",
+ "repo_count": "5",
+ "paid_count": "4",
+ "free_count": "1",
+ "repo_digest": "short",
},
)
for metadata in bad_metadata:
@@ -123,7 +174,7 @@ def test_paid_repos_rejects_foreign_or_malformed_metadata(self) -> None:
session["metadata"] = metadata
with self.subTest(metadata=metadata):
with self.assertRaises((PermissionError, ValueError)):
- service.paid_repos(session)
+ service.paid_order(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):
@@ -132,7 +183,7 @@ def test_operator_code_is_server_side_and_constant_time_comparable(self) -> None
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(service.operator_authorized("secret-code"))
- def test_checkout_form_binds_exact_repository_metadata(self) -> None:
+ def test_checkout_form_uses_paid_quantity_and_digest(self) -> None:
captured = {}
def fake_stripe_api(path, form=None):
@@ -140,20 +191,23 @@ def fake_stripe_api(path, form=None):
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)]
+ repos = [f"https://github.com/owner/repo-{index}" for index in range(11)]
with patch.object(service, "stripe_api", fake_stripe_api):
- session = service.create_checkout(repos, "bundle5")
+ session, pricing = service.create_checkout(repos)
self.assertEqual(session["id"], "cs_test")
+ self.assertEqual(pricing["repository_count"], 11)
+ self.assertEqual(pricing["paid_count"], 9)
+ self.assertEqual(pricing["free_count"], 2)
+ self.assertEqual(pricing["amount_cents"], 4500)
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)
+ self.assertEqual(captured["form"]["line_items[0][price]"], service.AUDIT_UNIT_PRICE_ID)
+ self.assertEqual(captured["form"]["line_items[0][quantity]"], "9")
+ self.assertEqual(captured["form"]["metadata[repo_count]"], "11")
+ self.assertEqual(captured["form"]["metadata[paid_count]"], "9")
+ self.assertEqual(captured["form"]["metadata[free_count]"], "2")
+ self.assertEqual(captured["form"]["metadata[repo_digest]"], service.repo_digest(repos))
+ self.assertFalse(any(key.startswith("metadata[repo_") and key != "metadata[repo_count]" and key != "metadata[repo_digest]" for key in captured["form"]))
if __name__ == "__main__":