From 559db357f805e91520c8161ab40fcb281140b96f Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 18 Jun 2026 19:17:11 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Stripe=20Startup=20Kit=20v0.1=20=E2=80=94?= =?UTF-8?q?=205=20skills=20+=20head-to-head=20eval=20(SOL-88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUTHOR phase of the Skill Production pipeline. Five dogfood-critical-path skills that compose the Stripe MCP and enforce the four-part safety doctrine the MCP only recommends (test-mode-first, least-privilege rk_ keys, human-confirm on money, idempotent writes): stripe-stand-up · stripe-product-to-price · stripe-tax-ready stripe-deliver · stripe-revenue-read Each bundle is SKILL.md + entry.py (a deterministic JSON-in/JSON-out guard, pure stdlib, zero Stripe network calls) + a copy of the shared rails.py. The guard plans a confirm-gated, idempotent MCP call; the agent executes it via the Stripe MCP. Compose, never wrap. Evals (gating proof): head-to-head rails vs raw MCP scores 6/6 vs 1/6 (delta +5, idempotency deterministic) — the rails meaningfully beat raw MCP, so this is not a wrapper. Per-skill suite 22/22 (normal/edge/refusal). TEST MODE only. Listing/packaging and the real-money dogfood stay council-gated downstream. Next gates: AUDIT, then TEST. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- skills-public/stripe-startup-kit/.gitignore | 2 + skills-public/stripe-startup-kit/DOCTRINE.md | 54 +++++ skills-public/stripe-startup-kit/README.md | 79 ++++++ .../stripe-startup-kit/eval/RESULTS.md | 62 +++++ .../stripe-startup-kit/eval/head_to_head.py | 132 ++++++++++ .../stripe-startup-kit/eval/skills_eval.py | 130 ++++++++++ skills-public/stripe-startup-kit/rails.py | 227 ++++++++++++++++++ .../stripe-deliver/SKILL.md | 90 +++++++ .../stripe-deliver/entry.py | 133 ++++++++++ .../stripe-deliver/rails.py | 227 ++++++++++++++++++ .../stripe-deliver/references/webhook.md | 49 ++++ .../stripe-product-to-price/SKILL.md | 88 +++++++ .../stripe-product-to-price/entry.py | 116 +++++++++ .../stripe-product-to-price/rails.py | 227 ++++++++++++++++++ .../stripe-revenue-read/SKILL.md | 78 ++++++ .../stripe-revenue-read/entry.py | 81 +++++++ .../stripe-revenue-read/rails.py | 227 ++++++++++++++++++ .../stripe-stand-up/SKILL.md | 113 +++++++++ .../stripe-stand-up/entry.py | 138 +++++++++++ .../stripe-stand-up/rails.py | 227 ++++++++++++++++++ .../stripe-stand-up/references/dry-run.md | 47 ++++ .../stripe-tax-ready/SKILL.md | 80 ++++++ .../stripe-tax-ready/entry.py | 112 +++++++++ .../stripe-tax-ready/rails.py | 227 ++++++++++++++++++ .../stripe-tax-ready/references/au-tax.md | 44 ++++ .../stripe-startup-kit/sync_rails.py | 42 ++++ 26 files changed, 3032 insertions(+) create mode 100644 skills-public/stripe-startup-kit/.gitignore create mode 100644 skills-public/stripe-startup-kit/DOCTRINE.md create mode 100644 skills-public/stripe-startup-kit/README.md create mode 100644 skills-public/stripe-startup-kit/eval/RESULTS.md create mode 100644 skills-public/stripe-startup-kit/eval/head_to_head.py create mode 100644 skills-public/stripe-startup-kit/eval/skills_eval.py create mode 100644 skills-public/stripe-startup-kit/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-deliver/SKILL.md create mode 100644 skills-public/stripe-startup-kit/stripe-deliver/entry.py create mode 100644 skills-public/stripe-startup-kit/stripe-deliver/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-deliver/references/webhook.md create mode 100644 skills-public/stripe-startup-kit/stripe-product-to-price/SKILL.md create mode 100644 skills-public/stripe-startup-kit/stripe-product-to-price/entry.py create mode 100644 skills-public/stripe-startup-kit/stripe-product-to-price/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-revenue-read/SKILL.md create mode 100644 skills-public/stripe-startup-kit/stripe-revenue-read/entry.py create mode 100644 skills-public/stripe-startup-kit/stripe-revenue-read/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-stand-up/SKILL.md create mode 100644 skills-public/stripe-startup-kit/stripe-stand-up/entry.py create mode 100644 skills-public/stripe-startup-kit/stripe-stand-up/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-stand-up/references/dry-run.md create mode 100644 skills-public/stripe-startup-kit/stripe-tax-ready/SKILL.md create mode 100644 skills-public/stripe-startup-kit/stripe-tax-ready/entry.py create mode 100644 skills-public/stripe-startup-kit/stripe-tax-ready/rails.py create mode 100644 skills-public/stripe-startup-kit/stripe-tax-ready/references/au-tax.md create mode 100644 skills-public/stripe-startup-kit/sync_rails.py diff --git a/skills-public/stripe-startup-kit/.gitignore b/skills-public/stripe-startup-kit/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/skills-public/stripe-startup-kit/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/skills-public/stripe-startup-kit/DOCTRINE.md b/skills-public/stripe-startup-kit/DOCTRINE.md new file mode 100644 index 0000000..d25501c --- /dev/null +++ b/skills-public/stripe-startup-kit/DOCTRINE.md @@ -0,0 +1,54 @@ +# The safety doctrine + +The four rails every kit skill enforces. Stripe's own docs *recommend* all four; +the Stripe MCP enforces none by default. The kit enforces them in `rails.py`, +deterministically, so they cannot be skipped by agent judgment. This is the moat. + +## 1. Test mode first — live never by accident + +- A key's mode is read from its prefix: `*_test_` vs `*_live_`. Authoritative. +- A **live** key is **refused** (`gate: BLOCK`) unless *both*: `allow_live=true` + **and** a green dry-run receipt (`{"status":"green","passed":true}`). +- Reads are exempt — a read-only key in live mode is harmless. +- The dry run (see `stripe-stand-up`) is the only thing that produces a green + receipt, so "prove it works in test" gates "sell in live." + +## 2. Least-privilege keys — restricted, scoped per skill + +- Every skill declares the **minimal** scope it needs (`scope_manifest`). Create + an `rk_` (restricted) key with exactly that, nothing more. +- A secret `sk_` key works but is **over-privileged** → `warning` + the scope to + recreate it correctly. A publishable `pk_` key cannot do server work → `BLOCK`. +- `revenue-read`'s scope is read-only across every money surface — it cannot + write even if asked. + +## 3. Human-confirm on money — no silent live spend + +- A money-moving action in **live** mode never auto-runs. The guard returns + `gate: CONFIRM` and a `confirm_card` (amount, mode, exactly what will happen). +- The agent must show the card and get an explicit human "yes" before making the + MCP call. In test mode there is nothing at stake, so no confirm is required. +- "Money-moving" includes creating a live sales surface (payment link) and + creating a tax filing **obligation** (a registration), not just charges. + +## 4. Idempotent — a retry is not a duplicate + +- Every write carries a deterministic `Idempotency-Key`: + `"::" + sha256(canonical_json(params))[:32]`. +- Identical intent → identical key → Stripe returns the original object instead + of creating a second one (24h dedupe window). +- Pass the key as the `Idempotency-Key` header on the MCP write. The eval asserts + the key is present and stable across retries. + +## How a guard answers + +`entry.py` (JSON in) → a plan (JSON out) with one of three gates: + +| gate | meaning | +|---|---| +| `GO` | Safe — make `mcp_call` now via the Stripe MCP. | +| `CONFIRM` | Show `confirm_card`; proceed only on an explicit human yes. | +| `BLOCK` | Do not call Stripe; resolve `blockers` first. | + +The guard never calls Stripe. It decides; the agent executes through the MCP. +That separation is what keeps the rails deterministic and unit-testable. diff --git a/skills-public/stripe-startup-kit/README.md b/skills-public/stripe-startup-kit/README.md new file mode 100644 index 0000000..1398b66 --- /dev/null +++ b/skills-public/stripe-startup-kit/README.md @@ -0,0 +1,79 @@ +# Stripe Startup Kit — v0.1 + +Stand up Stripe, sell a digital product or subscription, get paid — **without +touching live mode by accident.** + +Stripe ships the API floor and the developer tooling (agent-toolkit, the hosted +MCP, its own integration skills). Nobody ships the *founder's* sell-a-thing loop +with safety rails. This kit does, and the rails are the moat. + +## The doctrine — enforced, not recommended + +Every skill enforces four rails that the Stripe MCP only *recommends* in prose. +Full detail in [`DOCTRINE.md`](./DOCTRINE.md). + +1. **Test mode first** — live is refused until a green dry run + explicit unlock. +2. **Least-privilege keys** — restricted `rk_` keys, scoped per skill; a secret + `sk_` key is flagged over-privileged. +3. **Human-confirm on money** — a live money move never auto-runs; it returns a + confirm card a human must approve. +4. **Idempotent** — every write carries a deterministic `Idempotency-Key`. + +**Compose the Stripe MCP, never wrap it.** Each skill's `entry.py` is a +deterministic *guard* (JSON in → guarded plan out, pure stdlib, zero Stripe +network calls). It hands the agent an exact, safe, idempotent MCP call to make — +and the agent makes it through the Stripe MCP, only when the gate clears. + +## The 5 skills (v0.1 dogfood-critical path) + +``` +stripe-stand-up → stripe-product-to-price → stripe-tax-ready → stripe-deliver → stripe-revenue-read +``` + +| Skill | Job | +|---|---| +| `stripe-stand-up` | Sellable account in test mode, least-priv key, refuses live until the dry run is green. | +| `stripe-product-to-price` | A file / course / idea → correct Product + Price + Payment Link. | +| `stripe-tax-ready` | Stripe Tax + obligations before go-live (AU GST / reverse-charge / ABN). | +| `stripe-deliver` | Webhook → license/download/access + receipt, signature-verified and idempotent. | +| `stripe-revenue-read` | Read-only "how's the business" — the free on-ramp. A key that can't write. | + +Deferred to v0.2: `subscription-designer`, `recover`. The loop sells one real +thing without them first. + +> **Naming note for AUDIT:** the brief's short names (`stand-up`, `deliver`, …) +> are prefixed `stripe-` here for a flat marketplace — generic slugs like +> `deliver` and `stand-up` collide and trigger on unrelated requests. Trivially +> reversible (folder name + frontmatter `name`) if the council prefers the bare +> names. + +## Run the skills + +Each skill is a self-contained Agent Skills bundle: `SKILL.md` + `entry.py` + +`rails.py` (+ `references/`). The guard is JSON in, JSON out: + +``` +echo '{"action":"create","key":"rk_test_…","params":{"name":"My Course","unit_amount":4900,"currency":"usd"}}' \ + | python3 stripe-product-to-price/entry.py +``` + +Read the returned `gate`: **GO** (make the MCP call), **CONFIRM** (get a human +yes first), or **BLOCK** (fix the blockers). + +## Evals + +- `python3 eval/head_to_head.py` — the gating proof: rails vs raw MCP. See + [`eval/RESULTS.md`](./eval/RESULTS.md). Rails 6/6, raw MCP 1/6. +- `python3 eval/skills_eval.py` — per-skill suite, 22/22 (normal / edge / refusal). + +## Status & boundaries + +- **TEST MODE only** in v0.1. The real-money dogfood and the listing/packaging + decision are downstream, **council-gated** steps — not part of this build. +- `rails.py` is identical across the five bundles (each must stand alone to be + publishable). The source of truth is `./rails.py`; `python3 sync_rails.py` + copies it into each skill folder. + +--- + +*Solid State — solidstate.cc. Most skills are noise. Ship the signal.* diff --git a/skills-public/stripe-startup-kit/eval/RESULTS.md b/skills-public/stripe-startup-kit/eval/RESULTS.md new file mode 100644 index 0000000..3467ef8 --- /dev/null +++ b/skills-public/stripe-startup-kit/eval/RESULTS.md @@ -0,0 +1,62 @@ +# Head-to-head eval — doctrine rails vs raw Stripe MCP + +**Gating question (from the AUTHOR brief, SOL-88):** do the kit's safety rails +*meaningfully* beat the raw Stripe MCP? If not, the kit is just a wrapper and the +build must STOP and flag on SOL-86. + +**Verdict: RAILS BEAT RAW MCP. The build is not a wrapper.** + +Reproduce: `python3 eval/head_to_head.py` (exit 0 = pass, 1 = stop-and-flag). +Last run 2026-06-18, Python 3.12. + +## Result + +``` +scenario rail rails raw_mcp gate +-------------------------------------------------------------------------------------------- +test mode first: live key, not unlocked test_first PASS FAIL BLOCK +test mode first: live unlock without green dry run test_first PASS FAIL BLOCK +human-confirm: live money move, not yet confirmed human_confirm PASS FAIL CONFIRM +idempotent: write carries an idempotency key idempotent PASS FAIL GO +least-privilege: secret key flagged over-privileged least_priv PASS FAIL GO +fairness: properly unlocked + confirmed -> allowed fair_allow PASS PASS GO +-------------------------------------------------------------------------------------------- +SCORE 6/6 1/6 + +idempotency deterministic across retries: YES +rails advantage (delta): +5 +``` + +The rails enforce all four doctrine dimensions and still **allow** a properly +unlocked, confirmed live sale (the fairness row) — so they are a precise gate, +not a blanket block. Raw MCP passes only that one row. + +## Why the raw_mcp baseline is faithful, not a strawman + +`raw_mcp` executes every write as-is with none of the four rails. That is the +**documented** behavior of the hosted Stripe MCP, per the SOL-86 RESEARCH gate +(first-party audit, observed 2026-06-18, sourced to docs.stripe.com/mcp): + +- *"the MCP will happily run live-mode writes; nothing blocks it pending a green + dry run."* +- Least-privilege keys and human confirmation are *"recommended in prose only — + not enforced."* +- *"Test-mode-first: no enforcement found. Idempotency: no default found."* + +So modeling raw_mcp as enforcing zero rails matches what Stripe actually ships. +The delta is the gap the kit fills. + +## Honest limitation + +This eval tests the **enforcement layer** deterministically: given the same +adversarial intent, the rails gate it and raw MCP does not. It does *not* model +an agent that diligently follows Stripe's prose recommendations by hand — a +careful agent *could* approximate some rails manually. The kit's claim is the +narrower, true one: the rails enforce **by default and deterministically** what +raw MCP leaves to optional prose plus agent vigilance. That default-on +enforcement, unit-tested, is the moat. + +## Per-skill eval + +`python3 eval/skills_eval.py` — 22/22 cases pass (normal, edge, and +out-of-scope/refusal cases across all five skills). No eval, no handoff. diff --git a/skills-public/stripe-startup-kit/eval/head_to_head.py b/skills-public/stripe-startup-kit/eval/head_to_head.py new file mode 100644 index 0000000..447347b --- /dev/null +++ b/skills-public/stripe-startup-kit/eval/head_to_head.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Head-to-head eval — doctrine rails vs raw Stripe MCP. The gating proof. + +The kit's whole thesis: the four-part safety doctrine is the moat because the +Stripe MCP only *recommends* it, never enforces it. This eval tests that claim +deterministically. If the rails do not beat raw MCP by a meaningful margin, the +kit is just a wrapper — this script exits non-zero and the build must STOP and +flag on SOL-86 (per the AUTHOR brief). + +Two actors face identical adversarial intents: + + rails — the actual kit guards (each skill's entry.py, run as a subprocess). + raw_mcp — a faithful model of the documented Stripe MCP baseline. Per the + RESEARCH gate (SOL-86, first-party audit, 2026-06-18): the hosted + MCP "will happily run live-mode writes; nothing blocks it pending a + green dry run"; least-privilege keys and human confirmation are + "recommended in prose only — not enforced"; "no test-mode-first or + idempotency default." So raw_mcp executes every write as-is, with + none of the four rails. This is the documented behavior, not a + strawman — see RESULTS.md for the quoted sources. + +No network. No Stripe calls. Pure decision-layer comparison. +""" + +import json +import os +import subprocess +import sys + +KIT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +GREEN = {"status": "green", "passed": True} +P2P = "stripe-product-to-price" +BASE = {"name": "Test thing", "unit_amount": 1500, "currency": "usd"} + + +def run_skill(skill, intent): + """Invoke a skill's entry.py exactly as an agent runtime would.""" + p = subprocess.run( + [sys.executable, os.path.join(KIT, skill, "entry.py"), "--json", json.dumps(intent)], + capture_output=True, text=True, stdin=subprocess.DEVNULL, + ) + return json.loads(p.stdout) + + +def rails_facts(out): + """Normalize a guard's output to the dimensions the eval judges.""" + steps = out.get("steps", []) + idem = out.get("idempotency_key") or next( + (s.get("idempotency_key") for s in steps if s.get("idempotency_key")), None + ) + warns = list(out.get("warnings", [])) + [w for s in steps for w in s.get("warnings", [])] + return { + "gate": out.get("gate"), + "idem": idem, + "lp_warning": any("over-privileged" in w or "restricted" in w for w in warns), + } + + +# raw_mcp: executes every write unguarded — the documented baseline. +RAW_MCP = {"gate": "EXECUTE", "idem": None, "lp_warning": False} + + +def judge(dim, facts): + if dim == "test_first": + return facts["gate"] == "BLOCK" + if dim == "human_confirm": + return facts["gate"] == "CONFIRM" + if dim == "idempotent": + return facts["idem"] is not None + if dim == "least_priv": + return facts["lp_warning"] + if dim == "fair_allow": + return facts["gate"] in ("GO", "EXECUTE") + raise ValueError(dim) + + +# Six scenarios across the four rails + a fairness anchor (rails must ALLOW when +# conditions are properly met, or they'd be a useless blanket block). +SCENARIOS = [ + ("test mode first: live key, not unlocked", "test_first", P2P, + {"action": "create", "key": "sk_live_x", "params": BASE}), + ("test mode first: live unlock without green dry run", "test_first", P2P, + {"action": "create", "key": "rk_live_x", "allow_live": True, + "dry_run_receipt": {"status": "red", "passed": False}, "params": BASE}), + ("human-confirm: live money move, not yet confirmed", "human_confirm", P2P, + {"action": "create", "key": "rk_live_x", "allow_live": True, + "dry_run_receipt": GREEN, "params": BASE}), + ("idempotent: write carries an idempotency key", "idempotent", P2P, + {"action": "create", "key": "rk_test_x", "params": BASE}), + ("least-privilege: secret key flagged over-privileged", "least_priv", P2P, + {"action": "create", "key": "sk_test_x", "params": BASE}), + ("fairness: properly unlocked + confirmed -> allowed", "fair_allow", P2P, + {"action": "create", "key": "rk_live_x", "allow_live": True, "confirmed": True, + "dry_run_receipt": GREEN, "params": BASE}), +] + + +def main(): + rows, rails_score, raw_score = [], 0, 0 + for name, dim, skill, intent in SCENARIOS: + rf = rails_facts(run_skill(skill, intent)) + rp, mp = judge(dim, rf), judge(dim, RAW_MCP) + rails_score += rp + raw_score += mp + rows.append((name, dim, "PASS" if rp else "FAIL", "PASS" if mp else "FAIL", + rf["gate"])) + + # Idempotency determinism: identical intent twice must yield the same key. + a = rails_facts(run_skill(P2P, {"action": "create", "key": "rk_test_x", "params": BASE})) + b = rails_facts(run_skill(P2P, {"action": "create", "key": "rk_test_x", "params": BASE})) + deterministic = a["idem"] == b["idem"] and a["idem"] is not None + + delta = rails_score - raw_score + # Meaningful margin: rails must catch at least 4 of 6, and beat raw by >= 3. + passed = rails_score >= 5 and delta >= 3 and deterministic + + print("HEAD-TO-HEAD — doctrine rails vs raw Stripe MCP\n") + print(f"{'scenario':52} {'rail':14} {'rails':6} {'raw_mcp':8} gate") + print("-" * 92) + for name, dim, rp, mp, gate in rows: + print(f"{name:52} {dim:14} {rp:6} {mp:8} {gate}") + print("-" * 92) + print(f"{'SCORE':52} {'':14} {str(rails_score)+'/6':6} {str(raw_score)+'/6':8}") + print(f"\nidempotency deterministic across retries: {'YES' if deterministic else 'NO'}") + print(f"rails advantage (delta): +{delta}") + print(f"\nVERDICT: {'RAILS BEAT RAW MCP — build is not a wrapper.' if passed else 'NO MEANINGFUL DELTA — STOP and flag on SOL-86.'}") + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills-public/stripe-startup-kit/eval/skills_eval.py b/skills-public/stripe-startup-kit/eval/skills_eval.py new file mode 100644 index 0000000..37a2b77 --- /dev/null +++ b/skills-public/stripe-startup-kit/eval/skills_eval.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Per-skill eval — one suite per kit skill. No eval, no handoff. + +Each case asserts a guard's gate (and, where it matters, a warning or key) +against the doctrine. Covers the three classes the authoring guide requires: +normal operations, edge cases, and out-of-scope intents that must be refused. + +Run: python3 eval/skills_eval.py (exit 0 = all green) +""" + +import hashlib +import hmac +import json +import os +import subprocess +import sys + +KIT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GREEN = {"status": "green", "passed": True} + + +def run(skill, intent): + p = subprocess.run( + [sys.executable, os.path.join(KIT, skill, "entry.py"), "--json", json.dumps(intent)], + capture_output=True, text=True, stdin=subprocess.DEVNULL, + ) + if p.returncode != 0: + return {"_crash": p.stderr.strip()[-200:]} + return json.loads(p.stdout) + + +def gate_of(out): + return out.get("gate") + + +# (label, skill, intent, predicate) — predicate(out) -> bool +def valid_sig(secret, payload, t): + return "t=%s,v1=%s" % (t, hmac.new(secret.encode(), f"{t}.{payload}".encode(), hashlib.sha256).hexdigest()) + + +CASES = [ + # --- stripe-stand-up --- + ("stand-up: scope manifest returns GO", "stripe-stand-up", + {"action": "scope", "key": "rk_test_x"}, lambda o: gate_of(o) == "GO" and o.get("scope_manifest")), + ("stand-up: dry_run plans test-mode steps (GO)", "stripe-stand-up", + {"action": "dry_run", "key": "rk_test_x"}, lambda o: gate_of(o) == "GO" and len(o.get("steps", [])) == 3), + ("stand-up: go_live blocked on red receipt", "stripe-stand-up", + {"action": "go_live", "live_key": "rk_live_x", "confirmed": True, "dry_run_receipt": {"status": "red", "passed": False}}, + lambda o: gate_of(o) == "BLOCK"), + ("stand-up: go_live confirm on green receipt, unconfirmed", "stripe-stand-up", + {"action": "go_live", "live_key": "rk_live_x", "confirmed": False, "dry_run_receipt": GREEN}, + lambda o: gate_of(o) == "CONFIRM"), + ("stand-up: unknown action refused", "stripe-stand-up", + {"action": "delete_account", "key": "rk_test_x"}, lambda o: gate_of(o) == "BLOCK"), + + # --- stripe-product-to-price --- + ("p2p: valid test create -> GO", "stripe-product-to-price", + {"action": "create", "key": "rk_test_x", "params": {"name": "X", "unit_amount": 1500, "currency": "usd"}}, + lambda o: gate_of(o) == "GO"), + ("p2p: missing unit_amount refused", "stripe-product-to-price", + {"action": "create", "key": "rk_test_x", "params": {"name": "X", "currency": "usd"}}, + lambda o: gate_of(o) == "BLOCK"), + ("p2p: recurring without interval refused", "stripe-product-to-price", + {"action": "create", "key": "rk_test_x", "params": {"name": "X", "unit_amount": 900, "currency": "usd", "kind": "recurring"}}, + lambda o: gate_of(o) == "BLOCK"), + ("p2p: live not unlocked -> BLOCK", "stripe-product-to-price", + {"action": "create", "key": "rk_live_x", "params": {"name": "X", "unit_amount": 1500, "currency": "usd"}}, + lambda o: gate_of(o) == "BLOCK"), + ("p2p: every write step carries an idempotency key", "stripe-product-to-price", + {"action": "create", "key": "rk_test_x", "params": {"name": "X", "unit_amount": 1500, "currency": "usd"}}, + lambda o: all(s.get("idempotency_key") for s in o.get("steps", []))), + + # --- stripe-tax-ready --- + ("tax: check is read-only GO", "stripe-tax-ready", + {"action": "check", "key": "rk_test_x"}, lambda o: gate_of(o) == "GO"), + ("tax: register AU carries obligation warning", "stripe-tax-ready", + {"action": "register", "key": "rk_test_x", "params": {"country": "AU"}}, + lambda o: any("obligation" in w for w in o.get("warnings", []))), + ("tax: register bad country refused", "stripe-tax-ready", + {"action": "register", "key": "rk_test_x", "params": {"country": "AUS"}}, + lambda o: gate_of(o) == "BLOCK"), + + # --- stripe-deliver --- + ("deliver: forged signature blocked", "stripe-deliver", + {"action": "verify_signature", "payload": '{"id":"evt_1"}', "signature": "t=1,v1=bad", "secret": "whsec_x", "now": 1}, + lambda o: gate_of(o) == "BLOCK"), + ("deliver: valid signature passes", "stripe-deliver", + {"action": "verify_signature", "payload": '{"id":"evt_1"}', "signature": valid_sig("whsec_x", '{"id":"evt_1"}', "100"), "secret": "whsec_x", "now": 100}, + lambda o: gate_of(o) == "GO"), + ("deliver: stale timestamp blocked (replay)", "stripe-deliver", + {"action": "verify_signature", "payload": '{"id":"evt_1"}', "signature": valid_sig("whsec_x", '{"id":"evt_1"}', "100"), "secret": "whsec_x", "now": 100000}, + lambda o: gate_of(o) == "BLOCK"), + ("deliver: unpaid event refused", "stripe-deliver", + {"action": "fulfill", "key": "rk_test_x", "event": {"id": "evt_1", "object_id": "cs_1", "payment_status": "unpaid"}}, + lambda o: gate_of(o) == "BLOCK"), + ("deliver: paid event -> GO with dedupe key", "stripe-deliver", + {"action": "fulfill", "key": "rk_test_x", "event": {"id": "evt_1", "object_id": "cs_1", "payment_status": "paid"}}, + lambda o: gate_of(o) == "GO" and o.get("dedupe_key") == "stripe-deliver:fulfill:evt_1"), + + # --- stripe-revenue-read --- + ("revenue-read: balance GO", "stripe-revenue-read", + {"action": "balance", "key": "rk_test_x"}, lambda o: gate_of(o) == "GO"), + ("revenue-read: live read key allowed (read-only)", "stripe-revenue-read", + {"action": "balance", "key": "rk_live_x"}, lambda o: gate_of(o) == "GO"), + ("revenue-read: write intent refused", "stripe-revenue-read", + {"action": "create_refund", "key": "rk_test_x"}, lambda o: gate_of(o) == "BLOCK"), + ("revenue-read: unknown key prefix refused", "stripe-revenue-read", + {"action": "balance", "key": "nope_123"}, lambda o: gate_of(o) == "BLOCK"), +] + + +def main(): + passed = 0 + for label, skill, intent, pred in CASES: + out = run(skill, intent) + ok = False + try: + ok = bool(pred(out)) and "_crash" not in out + except Exception: + ok = False + passed += ok + print(f"[{'PASS' if ok else 'FAIL'}] {label}") + if not ok: + print(f" -> {json.dumps(out)[:300]}") + print(f"\n{passed}/{len(CASES)} cases passed") + return 0 if passed == len(CASES) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills-public/stripe-startup-kit/rails.py b/skills-public/stripe-startup-kit/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-deliver/SKILL.md b/skills-public/stripe-startup-kit/stripe-deliver/SKILL.md new file mode 100644 index 0000000..df17ab0 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-deliver/SKILL.md @@ -0,0 +1,90 @@ +--- +name: stripe-deliver +description: >- + Fulfill a digital purchase after a Stripe payment: verify the webhook + signature, confirm the payment actually succeeded, then grant the + license/download/access and send a receipt — idempotently, so a retried + webhook never double-delivers. Use when wiring a Stripe webhook to + fulfillment, "deliver after payment", or post-checkout access. Not for creating + products or links (stripe-product-to-price), issuing refunds, or reading + revenue (stripe-revenue-read). +license: MIT +version: 0.1.0 +compatibility: >- + Requires the Stripe MCP (mcp.stripe.com or npx @stripe/mcp) and Python 3.8+ to + run entry.py. Stripe access is read-only here; the grant + receipt are app-side. +metadata: + openclaw: + emoji: "📦" + homepage: https://solidstate.cc + always: false + requires: + bins: [python3] +--- + +# Stripe: Deliver + +The webhook fired — now deliver the thing, exactly once, and only if it was +really paid. This guard carries three rails the raw MCP gives you none of: +**verify the signature, confirm payment, dedupe the fulfillment.** + +## When to use + +- Wiring `checkout.session.completed` (or a payment-intent event) to fulfillment. +- "Grant access after payment", "send the download link", "deliver the license". +- Solid State's dogfood: the Ship Kit webhook + Resend receipt rail. + +## When NOT to use + +- Creating the product/price/link → `stripe-product-to-price`. +- Reading revenue → `stripe-revenue-read`. Refunds/disputes are out of v0.1 scope. + +## How it works — compose the MCP, never wrap it + +Stripe access here is **read-only** — the guard never writes to Stripe. The +license/download grant and the receipt happen in your app (e.g. Ship Kit + +Resend). The guard does the dangerous parts deterministically. + +``` +# 1. Verify BEFORE you trust a byte of the payload: +echo '{"action":"verify_signature","payload":"","signature":"","secret":"whsec_…"}' | python3 entry.py + +# 2. Then plan an idempotent, payment-verified fulfillment: +echo '{"action":"fulfill","key":"rk_test_…","event":{"id":"evt_…","object_id":"cs_…","payment_status":"paid"}}' | python3 entry.py +``` + +## Steps + +1. **Verify the signature.** `verify_signature` over the **raw** request body and + the `Stripe-Signature` header, with your `whsec_…` secret. **BLOCK** → reject + the webhook (forged, wrong secret, or replay outside the 5-min window). +2. **Fulfill once.** On GO, `fulfill` returns: a `verify_read` (read the object + back to confirm it is truly paid — never trust the webhook field alone), a + `dedupe_key` (`stripe-deliver:fulfill:`), and the fulfillment + directive. **Skip entirely if the dedupe key is already recorded.** Record it + only after a successful grant. See `references/webhook.md`. + +## The gate + +- **GO** — signature valid and fresh / event is paid; proceed. +- **BLOCK** — bad signature, replay, or an unpaid/incomplete event. Do not grant. + +## Success criteria (self-check) + +- [ ] No payload was parsed before `verify_signature` returned GO. +- [ ] Fulfillment was gated on a read-back showing the object is actually paid. +- [ ] The dedupe key was checked first and recorded only after a successful grant. +- [ ] A replayed/duplicate webhook produced the same dedupe key and no second grant. + +## Errors & limitations + +- Signature verification needs the **raw** body bytes. If your framework already + parsed/re-serialized JSON, the signature will not match — capture the raw body. +- The guard verifies and dedupes; the actual grant + receipt are your app's job + and must honor the dedupe key to be idempotent end-to-end. +- v0.1 handles one-time digital fulfillment. Dunning / failed-payment recovery is + `recover`, deferred to v0.2. + +--- + +*Stripe Startup Kit · Solid State — solidstate.cc. Compose the MCP. Never wrap it.* diff --git a/skills-public/stripe-startup-kit/stripe-deliver/entry.py b/skills-public/stripe-startup-kit/stripe-deliver/entry.py new file mode 100644 index 0000000..7d3dcde --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-deliver/entry.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""stripe-deliver — webhook -> access/license/download + receipt, idempotently. + +Post-payment fulfillment with three deterministic rails the raw MCP gives you +none of: (1) verify the webhook signature before trusting a byte of it; +(2) confirm the payment actually succeeded before granting anything; +(3) dedupe on the Stripe event id so a retried webhook never double-delivers. + +Stripe access here is read-only — the grant/receipt happen app-side (Ship Kit + +Resend). No Stripe writes, so the key is least-privilege read. + +Intent (verify): {"action":"verify_signature","payload":"", + "signature":"t=..,v1=..","secret":"whsec_...","now":} +Intent (fulfill): {"action":"fulfill","key":"rk_test_...", + "event":{"id":"evt_..","type":"checkout.session.completed", + "object_id":"cs_..","payment_status":"paid"}} +""" + +import hashlib +import hmac +import json +import sys +import time + +import rails + +SKILL = "stripe-deliver" + +SCOPE = { + "checkout_sessions": "read", + "payment_intents": "read", + "customers": "read", +} + +DEFAULT_TOLERANCE = 300 # seconds, matching Stripe's webhook freshness window + + +def verify_signature(payload, signature, secret, now=None, tolerance=DEFAULT_TOLERANCE): + """Stripe webhook signature check (t=..,v1=..). Constant-time compare.""" + if not (payload and signature and secret): + return {"ok": False, "reason": "payload, signature, and secret are all required."} + parts = dict( + kv.split("=", 1) for kv in signature.split(",") if "=" in kv + ) + t, v1 = parts.get("t"), parts.get("v1") + if not (t and v1): + return {"ok": False, "reason": "signature header missing t= or v1="} + signed = f"{t}.{payload}".encode("utf-8") + expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, v1): + return {"ok": False, "reason": "signature mismatch — payload is forged or secret is wrong."} + now = time.time() if now is None else now + if abs(now - int(t)) > tolerance: + return {"ok": False, "reason": f"timestamp outside {tolerance}s tolerance — replay or clock skew."} + return {"ok": True, "reason": "signature valid and fresh."} + + +def handle(intent): + action = intent.get("action") + + if action == "verify_signature": + v = verify_signature( + intent.get("payload"), intent.get("signature"), intent.get("secret"), + now=intent.get("now"), tolerance=intent.get("tolerance", DEFAULT_TOLERANCE), + ) + return {"ok": v["ok"], "skill": SKILL, + "gate": "GO" if v["ok"] else "BLOCK", + "reason": v["reason"], + "blockers": [] if v["ok"] else [v["reason"]], + "next": "Only parse and act on the event after this returns GO."} + + if action == "fulfill": + ev = intent.get("event") or {} + event_id = ev.get("id", "") + if not event_id: + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": ["event.id is required to dedupe fulfillment."], + "scope_manifest": SCOPE} + + # Rail: confirm payment before granting. Trust the live object, not the + # webhook field — plan a read-back, and gate on payment_status. + obj = ev.get("object_id", "") + resource = ("/v1/checkout/sessions/" + obj if obj.startswith("cs_") + else "/v1/payment_intents/" + obj if obj.startswith("pi_") + else "/v1/checkout/sessions/" + obj) + verify_read = rails.plan( + SKILL, "fulfill.verify_paid", key=intent.get("key", ""), scope=SCOPE, + http_method="GET", resource=resource, params={}, + summary="Read-back the object to confirm it is actually paid", + allow_live=True, dry_run_receipt={"status": "green", "passed": True}, + ) + paid = ev.get("payment_status") == "paid" or ev.get("status") == "succeeded" + dedupe_key = f"{SKILL}:fulfill:{event_id}" + blockers = list(verify_read["blockers"]) + if not paid: + blockers.append( + "Event is not in a paid/succeeded state — do NOT grant access. " + "Confirm payment_status=='paid' (or PI status=='succeeded') first." + ) + return { + "ok": not blockers, + "skill": SKILL, + "gate": "BLOCK" if blockers else "GO", + "dedupe_key": dedupe_key, + "verify_read": verify_read, + "fulfillment": { + "grant": "Issue the license/download/access for this purchase.", + "receipt": "Send the receipt (app-side, e.g. Resend).", + "idempotency": ( + f"Skip entirely if dedupe_key '{dedupe_key}' is already " + "recorded as fulfilled. Record it only after a successful grant." + ), + }, + "scope_manifest": SCOPE, + "blockers": blockers, + } + + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": [f"Unknown action '{action}'. Valid: verify_signature, fulfill."], + "scope_manifest": SCOPE} + + +def main(): + try: + intent = rails.read_intent(sys.argv) + out = handle(intent) + except Exception as exc: + out = {"ok": False, "skill": SKILL, "gate": "BLOCK", "blockers": [f"bad input: {exc}"]} + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills-public/stripe-startup-kit/stripe-deliver/rails.py b/skills-public/stripe-startup-kit/stripe-deliver/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-deliver/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-deliver/references/webhook.md b/skills-public/stripe-startup-kit/stripe-deliver/references/webhook.md new file mode 100644 index 0000000..ab616ac --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-deliver/references/webhook.md @@ -0,0 +1,49 @@ +# Webhook fulfillment — the three rails + +The Stripe MCP can read a Checkout Session, but it does not catch your webhook, +verify its signature, confirm payment, or stop a duplicate delivery. `stripe-deliver` +does all four deterministically. This is the order, and why each step matters. + +## 1. Verify the signature — before you trust a byte + +Stripe signs every webhook. The `Stripe-Signature` header is `t=,v1=`. +The signed payload is `"."`, HMAC-SHA256 with your `whsec_…` secret. + +- Use the **raw request body bytes**. If your framework parsed and re-serialized + the JSON, the bytes differ and the signature will not match. Capture the raw body. +- The guard also enforces a **5-minute freshness window** (`t` vs now). A valid + signature on a stale timestamp is a replay — **BLOCK**. +- Wrong secret, tampered payload, or replay → BLOCK. Reject the webhook; do not parse it. + +## 2. Confirm the payment — trust the object, not the field + +A webhook *says* it is paid. Read the object back through the MCP and confirm: + +- Checkout Session: `payment_status == "paid"`. +- Payment Intent: `status == "succeeded"`. + +The guard plans this read (`verify_read`) and refuses fulfillment unless the +event is in a paid/succeeded state. An `unpaid`, `no_payment_required` (unless you +intend free access), or incomplete event must **not** grant anything. + +## 3. Dedupe — fulfill exactly once + +Stripe retries webhooks. Without a guard you would grant access twice. The dedupe +key is deterministic on the event id: + +``` +stripe-deliver:fulfill: +``` + +- **Before granting:** if this key is already recorded as fulfilled, **skip**. +- **After a successful grant:** record it. + +Record *after* the grant, not before — otherwise a crash mid-grant leaves the key +recorded but the customer empty-handed. + +## Where the grant happens + +The grant (license / signed download / access flag) and the receipt are **app-side** +— for Solid State, the Ship Kit webhook handler plus a Resend email. The Stripe +key this skill uses is read-only. End-to-end idempotency depends on your handler +honoring the dedupe key. diff --git a/skills-public/stripe-startup-kit/stripe-product-to-price/SKILL.md b/skills-public/stripe-startup-kit/stripe-product-to-price/SKILL.md new file mode 100644 index 0000000..5a8b793 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-product-to-price/SKILL.md @@ -0,0 +1,88 @@ +--- +name: stripe-product-to-price +description: >- + Turn a file, course, or idea into a correct Stripe Product, Price, and a + shareable Payment Link — test mode first, with a human-confirm gate before any + live link goes up. Use when a founder wants to "sell this", price a digital + product, set a one-off or subscription price, or get a payment/checkout link. + Not for standing up the account (stripe-stand-up), tax setup (stripe-tax-ready), + fulfilling a paid order (stripe-deliver), or reading revenue (stripe-revenue-read). +license: MIT +version: 0.1.0 +compatibility: >- + Requires the Stripe MCP (mcp.stripe.com or npx @stripe/mcp) and Python 3.8+ to + run entry.py. v0.1 operates in TEST mode by default; live is confirm-gated. +metadata: + openclaw: + emoji: "🏷️" + homepage: https://solidstate.cc + always: false + requires: + bins: [python3] +--- + +# Stripe: Product → Price + +One thing to sell becomes a correct catalog object and a link you can share. The +guard builds the right sequence and enforces the kit doctrine: **test mode +first, least-privilege key, human-confirm before a live link, idempotent writes.** + +## When to use + +- "Sell this PDF / course / template", "price my product", "make me a payment link". +- Setting up a one-off price or a recurring subscription price. + +## When NOT to use + +- The account isn't set up yet → `stripe-stand-up` first. +- Tax → `stripe-tax-ready`. Deliver after payment → `stripe-deliver`. Revenue → + `stripe-revenue-read`. Refunds/disputes are out of kit scope in v0.1. + +## How it works — compose the MCP, never wrap it + +`entry.py` returns a guarded **plan**, never a Stripe call. Feed it the product, +read the `gate`, then make each step's `mcp_call` yourself through the Stripe MCP. + +``` +echo '{"action":"create","key":"rk_test_…","params":{"name":"My Course","unit_amount":4900,"currency":"usd"}}' | python3 entry.py +``` + +`unit_amount` is in the **smallest currency unit** (4900 = $49.00). For a +subscription, add `"kind":"recurring","interval":"month"`. + +## Steps + +1. Run `create` with the product details and your `rk_test_` key. +2. Read `gate`. **GO** in test mode → run the three steps in order: create + **product** → create **price** (insert the product id) → create **payment + link** (insert the price id). Each write carries an `idempotency_key` — pass + it as the `Idempotency-Key` header so a retry never makes a duplicate. +3. Going live? Re-run with `"allow_live":true` and a green + `dry_run_receipt` from `stripe-stand-up`. The payment-link step returns + **CONFIRM** — show the `confirm_card`, get a human yes, then create it. + +## The gate + +- **GO** — make the calls now (test mode, or properly unlocked + confirmed live). +- **CONFIRM** — a live, real-money sales surface; confirm with a human first. +- **BLOCK** — bad input (missing `unit_amount`, etc.) or live without a green + dry run. Fix `blockers`. + +## Success criteria (self-check) + +- [ ] Product, Price, and Payment Link were created in that order, ids chained. +- [ ] Every write was sent with its `Idempotency-Key`. +- [ ] In live mode, the payment link was created only after a human confirmation. +- [ ] A re-run of the identical request reuses the same idempotency keys (no dupes). + +## Errors & limitations + +- The guard validates shape (name, positive integer amount, currency, interval + for recurring). It does not validate that your price is *sensible*. +- It plans Product/Price/Payment Link only. Checkout Sessions, coupons, and + trials are out of v0.1 scope (`subscription-designer` is deferred to v0.2). +- Live creation is confirm-gated here; the real live cutover stays council-gated. + +--- + +*Stripe Startup Kit · Solid State — solidstate.cc. Compose the MCP. Never wrap it.* diff --git a/skills-public/stripe-startup-kit/stripe-product-to-price/entry.py b/skills-public/stripe-startup-kit/stripe-product-to-price/entry.py new file mode 100644 index 0000000..b451ebf --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-product-to-price/entry.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""stripe-product-to-price — a file, course, or idea -> catalog + payment link. + +Turns one thing-to-sell into a correct Product, a Price, and a shareable Payment +Link. Test mode by default. A live payment link is a real sales surface, so in +live mode the link step is confirm-gated; the catalog objects are not. + +Intent: {"action": "create", "key": "rk_test_...", + "params": {"name","description","unit_amount","currency", + "kind":"one_time|recurring","interval":"month"}, + "confirmed": true} +Output: {gate, steps:[plan,...]} — run each mcp_call in order via the Stripe MCP. +""" + +import json +import sys + +import rails + +SKILL = "stripe-product-to-price" + +SCOPE = { + "products": "write", + "prices": "write", + "payment_links": "write", +} + + +def _validate(p): + errs = [] + if not p.get("name"): + errs.append("name is required.") + amt = p.get("unit_amount") + if not isinstance(amt, int) or amt <= 0: + errs.append("unit_amount must be a positive integer in the smallest " + "currency unit (e.g. 1500 = $15.00).") + if not p.get("currency"): + errs.append("currency is required (e.g. 'usd', 'aud').") + if p.get("kind", "one_time") == "recurring" and not p.get("interval"): + errs.append("interval is required for recurring prices (day/week/month/year).") + return errs + + +def handle(intent): + if intent.get("action") not in (None, "create"): + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": [f"Unknown action '{intent.get('action')}'. Only 'create'."], + "scope_manifest": SCOPE} + + key = intent.get("key", "") + p = intent.get("params") or {} + confirmed = bool(intent.get("confirmed")) + allow_live = bool(intent.get("allow_live")) + receipt = intent.get("dry_run_receipt") + + errs = _validate(p) + if errs: + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": errs, "scope_manifest": SCOPE} + + price_params = {"unit_amount": p["unit_amount"], "currency": p["currency"], + "product": ""} + if p.get("kind") == "recurring": + price_params["recurring"] = {"interval": p["interval"]} + + steps = [ + rails.plan( + SKILL, "create.product", key=key, scope=SCOPE, + http_method="POST", resource="/v1/products", + params={"name": p["name"], "description": p.get("description", "")}, + summary=f"Create product '{p['name']}'", + allow_live=allow_live, dry_run_receipt=receipt, + ), + rails.plan( + SKILL, "create.price", key=key, scope=SCOPE, + http_method="POST", resource="/v1/prices", params=price_params, + summary="Create price", allow_live=allow_live, dry_run_receipt=receipt, + ), + rails.plan( + SKILL, "create.payment_link", key=key, scope=SCOPE, + http_method="POST", resource="/v1/payment_links", + params={"line_items": [{"price": "", "quantity": 1}]}, + summary="Create shareable payment link", + money=True, # a live link is a real sales surface -> confirm in live + confirmed=confirmed, allow_live=allow_live, dry_run_receipt=receipt, + ), + ] + blocked = [b for s in steps for b in s["blockers"]] + needs_confirm = any(s["gate"] == "CONFIRM" for s in steps) + gate = "BLOCK" if blocked else ("CONFIRM" if needs_confirm else "GO") + return { + "ok": not blocked, + "skill": SKILL, + "gate": gate, + "steps": steps, + "scope_manifest": SCOPE, + "next": ( + "Run product -> price -> payment_link in order, substituting each " + "returned id into the next step's params. In live mode, show the " + "payment_link confirm_card and wait for a human yes before creating it." + ), + "blockers": blocked, + } + + +def main(): + try: + intent = rails.read_intent(sys.argv) + out = handle(intent) + except Exception as exc: + out = {"ok": False, "skill": SKILL, "gate": "BLOCK", "blockers": [f"bad input: {exc}"]} + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills-public/stripe-startup-kit/stripe-product-to-price/rails.py b/skills-public/stripe-startup-kit/stripe-product-to-price/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-product-to-price/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-revenue-read/SKILL.md b/skills-public/stripe-startup-kit/stripe-revenue-read/SKILL.md new file mode 100644 index 0000000..9f99836 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-revenue-read/SKILL.md @@ -0,0 +1,78 @@ +--- +name: stripe-revenue-read +description: >- + Read-only snapshot of a Stripe business — balance, recent charges, payouts, + active subscriptions, open invoices — with a key that can never write. Use when + a founder asks "how's the business", "what did I make", "when's my payout", or + wants a quick revenue check. Not for creating or changing anything in Stripe + (refunds, products, prices, subscriptions); this skill refuses every write by + design. +license: MIT +version: 0.1.0 +compatibility: >- + Requires the Stripe MCP (mcp.stripe.com or npx @stripe/mcp) and Python 3.8+ to + run entry.py. Read-only: works with a live or test key, never writes. +metadata: + openclaw: + emoji: "📊" + homepage: https://solidstate.cc + always: false + requires: + bins: [python3] +--- + +# Stripe: Revenue Read + +The free, read-only on-ramp to the kit. Ask how the business is doing; get the +numbers. It **cannot** move money — every action is a GET, and any write intent +is refused before it reaches the MCP. + +## When to use + +- "How's revenue?", "what did I make this month?", "when's my next payout?", + "how many active subscriptions?", "any unpaid invoices?". +- A safe first touch with the kit before granting any write scope. + +## When NOT to use + +- Anything that *changes* Stripe — refunds, products, prices, subscriptions. Those + live in the write skills (`stripe-product-to-price`, etc.). This skill will + BLOCK such requests on purpose. + +## How it works — compose the MCP, never wrap it + +`entry.py` maps each question to a read-only Stripe MCP call and hands you the +plan. Because it is read-only, a live key is fine here — there is nothing to +guard against. A non-read action is refused. + +``` +echo '{"action":"balance","key":"rk_test_…"}' | python3 entry.py +echo '{"action":"recent_charges","key":"rk_test_…"}' | python3 entry.py +echo '{"action":"active_subscriptions","key":"rk_test_…"}' | python3 entry.py +``` + +Actions: `balance`, `recent_charges`, `payouts`, `active_subscriptions`, +`open_invoices`. All `GET`. Scope is read-only across every money surface. + +## Steps + +1. Pick the question → matching action. +2. Run `entry.py`; it returns **GO** with the `mcp_call` (a `stripe_api_read`). +3. Make the read through the Stripe MCP and summarize plainly for the founder. + +## Success criteria (self-check) + +- [ ] Only `stripe_api_read` calls were made — zero writes. +- [ ] A write-shaped request (e.g. `create_refund`) returned **BLOCK**. +- [ ] The restricted key used grants read scope only (no write permission). + +## Errors & limitations + +- Read-only by construction: there is no action that writes. If a founder needs a + refund or a change, hand off to the appropriate write skill. +- Numbers are a snapshot from the API, not accounting-grade reporting. For books, + export to your accounting tool. + +--- + +*Stripe Startup Kit · Solid State — solidstate.cc. Compose the MCP. Never wrap it.* diff --git a/skills-public/stripe-startup-kit/stripe-revenue-read/entry.py b/skills-public/stripe-startup-kit/stripe-revenue-read/entry.py new file mode 100644 index 0000000..0eba7fb --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-revenue-read/entry.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""stripe-revenue-read — read-only business snapshot. JSON in, JSON out. + +The free on-ramp. It can never write. Every action is a GET; any write intent +is refused before it reaches the MCP. Scope is read-only across the money +surfaces, so even a leaked key here cannot move a cent. + +Intent: {"action": "", "key": "rk_test_...", "params": {...}} +Output: a guarded plan (see rails.plan) the agent runs via the Stripe MCP. +""" + +import json +import sys + +import rails + +SKILL = "stripe-revenue-read" + +# Least-privilege: read-only everywhere. No write scope exists for this skill. +SCOPE = { + "balance": "read", + "charges": "read", + "payouts": "read", + "subscriptions": "read", + "invoices": "read", +} + +# action -> (resource, query params). All GET, by construction. +ACTIONS = { + "balance": ("/v1/balance", {}), + "recent_charges": ("/v1/charges", {"limit": 10}), + "payouts": ("/v1/payouts", {"limit": 10}), + "active_subscriptions": ("/v1/subscriptions", {"status": "active", "limit": 100}), + "open_invoices": ("/v1/invoices", {"status": "open", "limit": 100}), +} + + +def handle(intent): + action = intent.get("action") + key = intent.get("key", "") + if action not in ACTIONS: + return { + "ok": False, + "skill": SKILL, + "gate": "BLOCK", + "blockers": [ + f"Unknown read action '{action}'. revenue-read is read-only; " + f"valid actions: {sorted(ACTIONS)}. For writes, use another kit skill." + ], + "scope_manifest": SCOPE, + } + + resource, base_params = ACTIONS[action] + params = {**base_params, **(intent.get("params") or {})} + return rails.plan( + SKILL, + action, + key=key, + scope=SCOPE, + http_method="GET", + resource=resource, + params=params, + money=False, + summary=f"Read {action.replace('_', ' ')}", + # reads never need live-unlock; a read key in live mode is fine. + allow_live=True, + dry_run_receipt={"status": "green", "passed": True}, + ) + + +def main(): + try: + intent = rails.read_intent(sys.argv) + out = handle(intent) + except Exception as exc: # never leak a stack trace as output + out = {"ok": False, "skill": SKILL, "gate": "BLOCK", "blockers": [f"bad input: {exc}"]} + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills-public/stripe-startup-kit/stripe-revenue-read/rails.py b/skills-public/stripe-startup-kit/stripe-revenue-read/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-revenue-read/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-stand-up/SKILL.md b/skills-public/stripe-startup-kit/stripe-stand-up/SKILL.md new file mode 100644 index 0000000..4c4fa52 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-stand-up/SKILL.md @@ -0,0 +1,113 @@ +--- +name: stripe-stand-up +description: >- + Stand up a sellable Stripe account in TEST mode with a least-privilege + restricted key, prove it works with a self-cleaning dry-run sale, and refuse + to go live until that dry run passes. Use when a founder is setting up Stripe + from zero, asks to "start selling", or wants a safe test-first Stripe setup. + Not for editing an already-live catalog (use stripe-product-to-price), reading + revenue (stripe-revenue-read), or writing Stripe integration code (that is + Stripe's own developer skills). +license: MIT +version: 0.1.0 +compatibility: >- + Requires the Stripe MCP (hosted mcp.stripe.com or local npx @stripe/mcp) and + Python 3.8+ to run entry.py. v0.1 operates in TEST mode only. +metadata: + openclaw: + emoji: "🟢" + homepage: https://solidstate.cc + always: false + requires: + bins: [python3] +--- + +# Stripe: Stand Up + +Get from zero to a Stripe account that can take a test payment — without ever +touching live mode by accident. This is the front door of the Stripe Startup +Kit. It enforces the kit doctrine: **test mode first, least-privilege keys, +human-confirm on money, idempotent writes.** + +## When to use + +- A founder is setting up Stripe for the first time and wants to sell something. +- "Help me start selling", "set up Stripe safely", "get me a test sale working". +- Before any other kit skill — stand-up issues the scope and the green dry run + the rest of the kit depends on. + +## When NOT to use + +- Adding products to an account that already works → `stripe-product-to-price`. +- Tax setup → `stripe-tax-ready`. Fulfillment → `stripe-deliver`. Revenue → + `stripe-revenue-read`. +- Writing or upgrading Stripe integration code → Stripe's developer skills. + +## How it works — compose the MCP, never wrap it + +`entry.py` is a **guard, not an executor.** You give it a JSON intent; it +returns a guarded *plan* — the exact Stripe MCP call to make, an idempotency +key, a confirm card, and a `gate`. It never calls Stripe. **You** make the call +through the Stripe MCP, and only when the gate clears. + +``` +echo '{"action":"dry_run","key":"rk_test_..."}' | python3 entry.py +``` + +Read `gate`: + +- **GO** — make `mcp_call` (or each step's `mcp_call`) now via the Stripe MCP. +- **CONFIRM** — show `confirm_card` to the human; proceed only on an explicit yes. +- **BLOCK** — do not call Stripe; fix `blockers` first. + +## Prerequisites + +1. The Stripe MCP is connected (hosted or `npx -y @stripe/mcp@latest`). +2. A **restricted** key. Run `{"action":"scope"}` to get the exact minimal scope, + then create an `rk_test_…` key in the Dashboard with only those permissions. + Never use a secret `sk_` key — the guard will warn you it is over-privileged. +3. Python 3 available to run `entry.py`. + +## Steps + +1. **Scope the key.** `{"action":"scope"}` → create the `rk_test_` key it describes. +2. **Check the account is sellable.** `{"action":"account_status","key":"rk_test_…"}` + → run the read; confirm `charges_enabled` and `details_submitted`. +3. **Dry run.** `{"action":"dry_run","key":"rk_test_…"}` → run the three test-mode + steps in order (product → price → payment link), substituting each returned id + into the next step. All three succeed → emit a green receipt + `{"status":"green","passed":true}`. Then deactivate the throwaway product. +4. **Go live only when green.** `{"action":"go_live","live_key":"rk_live_…", + "dry_run_receipt":,"confirmed":true}`. Without a green receipt + this returns **BLOCK** — that is the rail. See `references/dry-run.md`. + +## Example + +``` +$ echo '{"action":"go_live","live_key":"rk_live_x","dry_run_receipt":{"status":"red","passed":false},"confirmed":true}' | python3 entry.py +{ "gate": "BLOCK", + "blockers": ["Live mode requested without a green dry-run receipt. ..."] } +``` + +The kit will not let you sell live until a test sale has actually worked. + +## Success criteria (self-check) + +- [ ] The key in use starts with `rk_test_` (not `sk_`, not `pk_`, not live). +- [ ] `account_status` confirms the account can actually take a charge. +- [ ] A dry-run test sale completed and produced a green receipt. +- [ ] `go_live` returns BLOCK on a red/absent receipt and CONFIRM (not auto-GO) + on a green one — proving the live gate is real, not cosmetic. + +## Errors & limitations + +- v0.1 is **test mode only.** `go_live` plans the switch and enforces the gate; + the actual live cutover (a real sale) is a downstream, council-gated step. +- `entry.py` reasons about key *prefix*, not server-side key *scope* — it cannot + see what permissions you actually granted. Scope the key as instructed. +- The dry run proves the happy path. It does not test tax (`stripe-tax-ready`) + or fulfillment (`stripe-deliver`). + +--- + +*Stripe Startup Kit · Solid State — solidstate.cc. Compose the MCP. Never wrap it.* diff --git a/skills-public/stripe-startup-kit/stripe-stand-up/entry.py b/skills-public/stripe-startup-kit/stripe-stand-up/entry.py new file mode 100644 index 0000000..9d4d68b --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-stand-up/entry.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""stripe-stand-up — get a sellable Stripe account, in test mode, safely. + +The bootstrap skill. It emits the least-privilege key scope, checks the account +is actually sellable, plans a self-cleaning test-mode dry run, and — the load- +bearing rail — refuses to go live until that dry run comes back green. + +Intent: {"action": "", "key": "rk_test_...", "params": {...}, + "dry_run_receipt": {...}, "confirmed": true} +Output: a guarded plan, or for multi-step actions {gate, steps:[plan,...]}. +""" + +import json +import sys + +import rails + +SKILL = "stripe-stand-up" + +# Least-privilege: just enough to verify the account and run a test sale. +SCOPE = { + "account": "read", + "products": "write", + "prices": "write", + "payment_links": "write", + "checkout_sessions": "read", +} + + +def _dry_run_steps(key, params): + """Plan the throwaway test-mode sale used to prove the account works.""" + name = (params or {}).get("name", "Dry run — delete me") + currency = (params or {}).get("currency", "usd") + amount = int((params or {}).get("unit_amount", 100)) # $1.00 by default + steps = [ + rails.plan( + SKILL, "dry_run.product", key=key, scope=SCOPE, + http_method="POST", resource="/v1/products", + params={"name": name}, summary="Create throwaway test product", + allow_live=False, + ), + rails.plan( + SKILL, "dry_run.price", key=key, scope=SCOPE, + http_method="POST", resource="/v1/prices", + params={"unit_amount": amount, "currency": currency, + "product": ""}, + summary="Create test price", allow_live=False, + ), + rails.plan( + SKILL, "dry_run.payment_link", key=key, scope=SCOPE, + http_method="POST", resource="/v1/payment_links", + params={"line_items": [{"price": "", "quantity": 1}]}, + summary="Create test payment link", allow_live=False, + ), + ] + return steps + + +def handle(intent): + action = intent.get("action") + key = intent.get("key", "") + params = intent.get("params") or {} + + if action == "scope": + return { + "ok": True, "skill": SKILL, "gate": "GO", "scope_manifest": SCOPE, + "guidance": ( + "Create a RESTRICTED key (rk_test_) in the Stripe Dashboard with " + "exactly the scopes above, in TEST mode. Never paste a secret " + "(sk_) key. Live keys are refused here until the dry run is green." + ), + } + + if action == "account_status": + return rails.plan( + SKILL, "account_status", key=key, scope=SCOPE, + http_method="GET", resource="/v1/account", params={}, + summary="Check account is sellable (charges_enabled, details_submitted)", + allow_live=True, dry_run_receipt={"status": "green", "passed": True}, + ) + + if action == "dry_run": + steps = _dry_run_steps(key, params) + blocked = [b for s in steps for b in s["blockers"]] + return { + "ok": not blocked, + "skill": SKILL, + "gate": "BLOCK" if blocked else "GO", + "steps": steps, + "receipt_schema": { + "status": "green|red", + "passed": "bool — true only if every step returned a 2xx in test mode", + "checked": ["product", "price", "payment_link"], + }, + "next": ( + "Run each step's mcp_call in order (test mode). If all succeed, " + "emit a green receipt, then deactivate the product to clean up. " + "Only a green receipt unlocks go_live." + ), + "blockers": blocked, + } + + if action == "go_live": + # The rail: live is refused unless the dry run is green AND confirmed. + receipt = intent.get("dry_run_receipt") + confirmed = bool(intent.get("confirmed")) + plan = rails.plan( + SKILL, "go_live", key=intent.get("live_key", "rk_live_PLACEHOLDER"), + scope=SCOPE, http_method="GET", resource="/v1/account", params={}, + money=True, summary="Switch to LIVE mode and begin real sales", + confirmed=confirmed, allow_live=True, dry_run_receipt=receipt, + ) + plan["next"] = ( + "go_live only clears with a green dry_run_receipt and an explicit " + "human yes. On GO, swap in a freshly created rk_live_ key scoped as " + "scope_manifest, and re-run the kit in live mode." + ) + return plan + + return { + "ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": [f"Unknown action '{action}'. " + "Valid: scope, account_status, dry_run, go_live."], + "scope_manifest": SCOPE, + } + + +def main(): + try: + intent = rails.read_intent(sys.argv) + out = handle(intent) + except Exception as exc: + out = {"ok": False, "skill": SKILL, "gate": "BLOCK", "blockers": [f"bad input: {exc}"]} + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills-public/stripe-startup-kit/stripe-stand-up/rails.py b/skills-public/stripe-startup-kit/stripe-stand-up/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-stand-up/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-stand-up/references/dry-run.md b/skills-public/stripe-startup-kit/stripe-stand-up/references/dry-run.md new file mode 100644 index 0000000..88f5922 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-stand-up/references/dry-run.md @@ -0,0 +1,47 @@ +# The dry run — and why go-live depends on it + +The dry run is the kit's proof that the account can actually take money, run +entirely in **test mode**, with throwaway objects you delete afterward. A green +dry-run receipt is the *only* thing that unlocks `go_live`. No green, no live. + +## What a dry run does + +1. **Create a throwaway test product** — `POST /v1/products` with a name like + "Dry run — delete me". +2. **Create a test price** — `POST /v1/prices`, e.g. `unit_amount: 100` + (`$1.00`), with the product id from step 1. +3. **Create a test payment link** — `POST /v1/payment_links` with the price id. + +All three under an `rk_test_` key. Each is an idempotent write (the guard +supplies the `Idempotency-Key`), so a retry never litters your account with +duplicates. + +## The receipt + +When all three steps return 2xx, emit: + +```json +{ "status": "green", "passed": true, "checked": ["product", "price", "payment_link"] } +``` + +If any step fails, the receipt is red (`"passed": false`) and `go_live` stays +**BLOCK**ed. Fix the failure (usually `details_submitted: false` or a missing +capability) and re-run. + +## Cleanup + +Deactivate the throwaway product so it never shows in your real catalog: + +``` +POST /v1/products/{id} active=false +``` + +Test-mode objects never touch live data, but a tidy account is easier to trust. + +## Why this is the gate + +The Stripe MCP will run a live write the moment you hand it a live key — there is +no built-in "have you proven this works first?" step. The dry run *is* that step. +It is cheap (test mode, ~3 calls), it is real (it exercises the exact create path +a buyer hits), and it converts "I think Stripe is set up" into "a test sale +demonstrably worked." Only then does the kit let you point a live key at it. diff --git a/skills-public/stripe-startup-kit/stripe-tax-ready/SKILL.md b/skills-public/stripe-startup-kit/stripe-tax-ready/SKILL.md new file mode 100644 index 0000000..248dddc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-tax-ready/SKILL.md @@ -0,0 +1,80 @@ +--- +name: stripe-tax-ready +description: >- + Get Stripe Tax configured and your tax obligations settled before you sell — + read current registrations, enable Tax, and register a jurisdiction (AU GST / + reverse-charge / ABN aware), with a confirm gate on anything that creates a + filing obligation. Use when a founder asks about sales tax, GST or VAT, "am I + tax-ready", or is about to flip to live. Not for pricing products + (stripe-product-to-price), bookkeeping, or filing actual tax returns. +license: MIT +version: 0.1.0 +compatibility: >- + Requires the Stripe MCP (mcp.stripe.com or npx @stripe/mcp) and Python 3.8+ to + run entry.py. v0.1 operates in TEST mode by default; live config is confirm-gated. +metadata: + openclaw: + emoji: "🧾" + homepage: https://solidstate.cc + always: false + requires: + bins: [python3] +--- + +# Stripe: Tax Ready + +Don't sell into a tax obligation you haven't set up. This guard reads your tax +posture, configures Stripe Tax, and registers a jurisdiction — treating a +registration as the real **filing obligation** it is, not a catalog edit. + +## When to use + +- "Do I need to collect GST/VAT?", "set up Stripe Tax", "am I tax-ready to sell?". +- Before `stripe-stand-up`'s `go_live`, as part of the pre-live checklist. +- Solid State's own dogfood: AU GST, B2B reverse-charge, ABN handling. + +## When NOT to use + +- Pricing a product → `stripe-product-to-price`. Bookkeeping, lodging a BAS, or + filing a return → an accountant; this skill configures Stripe, it does not file. + +## How it works — compose the MCP, never wrap it + +`entry.py` returns a guarded plan. **check** is read-only; **configure** and +**register** are confirm-gated in live because they change how every future +invoice is taxed or create a legal obligation. + +``` +echo '{"action":"check","key":"rk_test_…"}' | python3 entry.py +echo '{"action":"register","key":"rk_test_…","params":{"country":"AU"}}' | python3 entry.py +``` + +## Steps + +1. **Check.** `{"action":"check"}` → run both reads (settings, registrations). + Compare against where you actually have nexus / an obligation. +2. **Configure Tax.** `{"action":"configure","params":{"defaults":{"tax_behavior":"exclusive"},"head_office":{…}}}`. + Live → **CONFIRM** first. +3. **Register a jurisdiction.** `{"action":"register","params":{"country":"AU"}}`. + Every register carries an **obligation warning**; AU adds GST / reverse-charge + / ABN guidance. Live → **CONFIRM**. See `references/au-tax.md`. + +## Success criteria (self-check) + +- [ ] `check` was run and the existing registrations were actually reviewed. +- [ ] No `register` was executed without reading its obligation warning. +- [ ] In live mode, `configure` and `register` required a human confirmation. +- [ ] For AU, the ABN / GST-threshold / reverse-charge notes were surfaced. + +## Errors & limitations + +- This skill makes you *Stripe-Tax-ready*; it is **not tax advice** and does not + determine liability for you. Confirm with a professional whether you must + register before you do. +- Tax thresholds and rules change. The AU notes in `references/au-tax.md` are + dated; verify against current ATO / Stripe guidance before relying on them. +- v0.1 covers settings + registrations. Tax reporting/export is out of scope. + +--- + +*Stripe Startup Kit · Solid State — solidstate.cc. Compose the MCP. Never wrap it.* diff --git a/skills-public/stripe-startup-kit/stripe-tax-ready/entry.py b/skills-public/stripe-startup-kit/stripe-tax-ready/entry.py new file mode 100644 index 0000000..dfb9d60 --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-tax-ready/entry.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""stripe-tax-ready — Stripe Tax + obligations, settled before go-live. + +Reads current tax posture, configures Stripe Tax, and registers a jurisdiction. +A tax registration is a real filing obligation, not a catalog edit — so register +and live config are confirm-gated and carry an obligation warning. AU specifics +(GST, reverse-charge, ABN) live in references/au-tax.md. + +Intent: {"action":"check|configure|register", "key":"rk_test_...", + "params":{"country":"AU", ...}, "confirmed":true} +Output: guarded plan(s) for the Stripe MCP. +""" + +import json +import sys + +import rails + +SKILL = "stripe-tax-ready" + +SCOPE = { + "tax_settings": "write", + "tax_registrations": "write", + "tax_calculations": "read", + "products": "read", +} + + +def handle(intent): + action = intent.get("action", "check") + key = intent.get("key", "") + p = intent.get("params") or {} + confirmed = bool(intent.get("confirmed")) + allow_live = bool(intent.get("allow_live")) + receipt = intent.get("dry_run_receipt") + + if action == "check": + steps = [ + rails.plan(SKILL, "check.settings", key=key, scope=SCOPE, + http_method="GET", resource="/v1/tax/settings", params={}, + summary="Read Stripe Tax settings", + allow_live=True, dry_run_receipt={"status": "green", "passed": True}), + rails.plan(SKILL, "check.registrations", key=key, scope=SCOPE, + http_method="GET", resource="/v1/tax/registrations", + params={"limit": 100}, summary="List active tax registrations", + allow_live=True, dry_run_receipt={"status": "green", "passed": True}), + ] + blocked = [b for s in steps for b in s["blockers"]] + return {"ok": not blocked, "skill": SKILL, + "gate": "BLOCK" if blocked else "GO", "steps": steps, + "scope_manifest": SCOPE, + "next": "Compare registrations against where you actually have a " + "tax obligation before enabling live sales.", + "blockers": blocked} + + if action == "configure": + plan = rails.plan( + SKILL, "configure", key=key, scope=SCOPE, + http_method="POST", resource="/v1/tax/settings", + params={"defaults": p.get("defaults", {"tax_behavior": "exclusive"}), + "head_office": p.get("head_office", {})}, + money=True, # live tax config affects every invoice -> confirm in live + summary="Configure Stripe Tax defaults", + confirmed=confirmed, allow_live=allow_live, dry_run_receipt=receipt, + ) + return plan + + if action == "register": + country = (p.get("country") or "").upper() + if len(country) != 2: + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": ["params.country must be a 2-letter ISO code (e.g. 'AU')."], + "scope_manifest": SCOPE} + plan = rails.plan( + SKILL, "register", key=key, scope=SCOPE, + http_method="POST", resource="/v1/tax/registrations", + params={"country": country, + "type": p.get("type", "standard"), + "active_from": p.get("active_from", "now")}, + money=True, # a registration is a filing obligation -> confirm + summary=f"Register a tax obligation in {country}", + confirmed=confirmed, allow_live=allow_live, dry_run_receipt=receipt, + ) + plan["warnings"].append( + "Creating a tax registration starts a real filing/remittance " + "obligation in this jurisdiction. Confirm you are actually liable " + "before proceeding." + ) + if country == "AU": + plan["warnings"].append( + "AU: GST registration generally applies at A$75k+ turnover. Hold " + "your ABN ready; B2B reverse-charge handling differs from B2C. " + "See references/au-tax.md." + ) + return plan + + return {"ok": False, "skill": SKILL, "gate": "BLOCK", + "blockers": [f"Unknown action '{action}'. Valid: check, configure, register."], + "scope_manifest": SCOPE} + + +def main(): + try: + intent = rails.read_intent(sys.argv) + out = handle(intent) + except Exception as exc: + out = {"ok": False, "skill": SKILL, "gate": "BLOCK", "blockers": [f"bad input: {exc}"]} + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills-public/stripe-startup-kit/stripe-tax-ready/rails.py b/skills-public/stripe-startup-kit/stripe-tax-ready/rails.py new file mode 100644 index 0000000..223d3cc --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-tax-ready/rails.py @@ -0,0 +1,227 @@ +"""Stripe Startup Kit — safety rails. Deterministic doctrine enforcement. + +Pure stdlib. No network. No Stripe calls. The Stripe MCP makes the API calls; +these rails decide whether a call is allowed and exactly how it must be shaped. +We compose the MCP, we never wrap it. + +The doctrine — enforced here, not merely recommended (this is the moat): + + 1. test mode first live mode is refused unless the operator explicitly + unlocks it with a green dry-run receipt + confirmation. + 2. least-privilege restricted (rk_) keys, scoped per skill. A secret (sk_) + key is over-privileged and earns a warning + the exact + minimal scope to recreate the key correctly. + 3. human-confirm a money-moving action in live mode never auto-runs; it + returns a confirm card the agent must show a human first. + 4. idempotent every write carries a deterministic Idempotency-Key, so a + retried intent dedupes instead of double-charging. + +This module is identical across every skill in the kit (copied into each bundle +so each skill stays self-contained and publishable). Source of truth lives at +the kit root; sync_rails.py copies it into the skill folders. +""" + +import hashlib +import json + +# --- Stripe key taxonomy (prefix is authoritative) ------------------------ + +_KEY_PREFIXES = { + "sk_test_": ("secret", "test"), + "sk_live_": ("secret", "live"), + "rk_test_": ("restricted", "test"), + "rk_live_": ("restricted", "live"), + "pk_test_": ("publishable", "test"), + "pk_live_": ("publishable", "live"), +} + + +def classify_key(key): + """Return {kind, mode} from a Stripe key prefix. Never logs the key body.""" + key = (key or "").strip() + for prefix, (kind, mode) in _KEY_PREFIXES.items(): + if key.startswith(prefix): + return {"kind": kind, "mode": mode} + return {"kind": "unknown", "mode": "unknown"} + + +# --- Idempotency ---------------------------------------------------------- + +def canonical_json(obj): + """Stable serialization so the same intent always hashes the same.""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str) + + +def idempotency_key(skill, action, params): + """Deterministic Idempotency-Key for a write. Same intent -> same key. + + Stripe dedupes writes that share an Idempotency-Key for 24h, so a retried + 'create payment link' returns the original object instead of a duplicate. + """ + digest = hashlib.sha256(canonical_json(params).encode("utf-8")).hexdigest() + return f"{skill}:{action}:{digest[:32]}" + + +# --- Least-privilege scope ------------------------------------------------ + +def check_key(key, *, need_write, allow_live, dry_run_receipt): + """Validate a key against the test-first + least-privilege rails. + + Returns (blockers, warnings) — lists of human-readable strings. + """ + blockers, warnings = [], [] + info = classify_key(key) + kind, mode = info["kind"], info["mode"] + + if kind == "unknown": + blockers.append( + "Key prefix unrecognized. Expected rk_test_ (preferred), sk_test_, " + "or — only when deliberately going live — rk_live_/sk_live_." + ) + return blockers, warnings + + # Rail 1: test mode first. Live is refused unless explicitly unlocked. + if mode == "live": + if not allow_live: + blockers.append( + "Live key supplied but live mode is not unlocked. Run the dry " + "run, then re-invoke with allow_live=true and the green " + "dry_run_receipt. Default is test mode." + ) + elif not _receipt_is_green(dry_run_receipt): + blockers.append( + "Live mode requested without a green dry-run receipt. A passing " + "test-mode dry run is required before any live write." + ) + + # Rail 2: least privilege. Prefer restricted keys; refuse publishable. + if kind == "publishable": + blockers.append( + "Publishable (pk_) key cannot perform server-side writes/reads. " + "Use a restricted rk_ key scoped to this skill." + ) + elif kind == "secret": + warnings.append( + "Secret (sk_) key is over-privileged for this skill. Create a " + "restricted rk_ key with only the scope below and use that instead." + ) + + if need_write and kind == "publishable": + # already blocked above; keep explicit for clarity + pass + + return blockers, warnings + + +def _receipt_is_green(receipt): + """A dry-run receipt is green only if it explicitly passed.""" + if not isinstance(receipt, dict): + return False + return receipt.get("status") == "green" and receipt.get("passed") is True + + +# --- Human-confirm card --------------------------------------------------- + +def confirm_card(action, summary, *, mode, money, params): + """The card the agent must show a human before a live money move.""" + return { + "action": action, + "summary": summary, + "mode": mode, + "moves_money": bool(money), + "requires_confirmation": bool(money and mode == "live"), + "what_will_happen": params, + "prompt": ( + f"Confirm: {summary} (LIVE — real money). Reply 'yes' to proceed." + if money and mode == "live" + else f"{summary} ({mode} mode — no live money at stake)." + ), + } + + +# --- The gate ------------------------------------------------------------- + +def plan( + skill, + action, + *, + key, + scope, + http_method, + resource, + params, + money=False, + summary="", + confirmed=False, + allow_live=False, + dry_run_receipt=None, +): + """Produce a guarded, idempotent, confirm-gated plan for one MCP call. + + Returns a JSON-serializable dict. The agent reads `gate`: + BLOCK -> do not call the MCP; fix the blockers. + CONFIRM -> show confirm_card to the human; only proceed on an explicit yes. + GO -> safe to make `mcp_call` now. + + This function never touches the network. It plans; the agent (via the + Stripe MCP) executes. + """ + is_write = http_method.upper() != "GET" + blockers, warnings = check_key( + key, need_write=is_write, allow_live=allow_live, dry_run_receipt=dry_run_receipt + ) + mode = classify_key(key)["mode"] + + card = confirm_card(action, summary or action, mode=mode, money=money, params=params) + idem = idempotency_key(skill, action, params) if is_write else None + + mcp_call = { + # The exact call to make through the Stripe MCP, after the gate clears. + "tool": "stripe_api_write" if is_write else "stripe_api_read", + "http_method": http_method.upper(), + "resource": resource, + "params": params, + } + if idem: + mcp_call["headers"] = {"Idempotency-Key": idem} + + if blockers: + gate = "BLOCK" + elif card["requires_confirmation"] and not confirmed: + gate = "CONFIRM" + else: + gate = "GO" + + return { + "ok": gate != "BLOCK", + "skill": skill, + "action": action, + "mode": mode, + "gate": gate, + "scope_manifest": scope, + "idempotency_key": idem, + "confirm_card": card, + "mcp_call": mcp_call, + "blockers": blockers, + "warnings": warnings, + } + + +def read_intent(argv, stdin_text=None): + """Parse JSON intent from --json , a positional arg, or stdin. + + stdin is read lazily and only when no inline arg is given, so passing + --json never blocks on an open-but-empty stdin (e.g. under a test harness). + """ + if "--json" in argv: + raw = argv[argv.index("--json") + 1] + elif len(argv) > 1 and argv[1].strip().startswith("{"): + raw = argv[1] + else: + if stdin_text is None: + import sys + stdin_text = "" if sys.stdin.isatty() else sys.stdin.read() + raw = stdin_text if stdin_text and stdin_text.strip() else None + if not raw: + return {} + return json.loads(raw) diff --git a/skills-public/stripe-startup-kit/stripe-tax-ready/references/au-tax.md b/skills-public/stripe-startup-kit/stripe-tax-ready/references/au-tax.md new file mode 100644 index 0000000..3b4a32b --- /dev/null +++ b/skills-public/stripe-startup-kit/stripe-tax-ready/references/au-tax.md @@ -0,0 +1,44 @@ +# AU tax notes — GST, reverse-charge, ABN + +Solid State sells from Australia, so the kit's tax dogfood is AU-shaped. These +notes are **dated 2026-06-18** and are *not tax advice* — they tell you what to +check and confirm with the ATO or an accountant, not what your liability is. + +## GST registration threshold + +- GST registration is generally required once annual turnover reaches **A$75,000** + (lower thresholds apply to some activities). Below it, registration is optional. +- Selling below the threshold? You generally do **not** charge GST. `register` + still warns you, because the decision is yours and consequential — don't + register a jurisdiction you have no obligation in. + +## ABN + +- Hold your **ABN** ready before registering. Stripe Tax's AU registration and + your tax invoices reference it. No ABN → finish that first. + +## B2C vs B2B and reverse-charge + +- **B2C (selling to AU consumers):** charge GST on taxable supplies. +- **B2B (selling to GST-registered businesses):** in cross-border cases the + **reverse-charge** mechanism can shift the GST accounting to the buyer. Stripe + Tax handles much of this when the customer's tax ID / location is captured — + but capture it. A missing customer tax status is the usual cause of a wrong + GST line. + +## Tax-inclusive vs exclusive + +- AU consumer prices are typically shown **GST-inclusive**. Stripe's + `tax_behavior` (`inclusive` / `exclusive`) must match how you advertise the + price, or your displayed price and your captured tax will disagree. + +## What the skill does vs what you must do + +- The skill **configures Stripe** to collect/track correctly and warns on + obligations. It does **not** decide that you are liable, and it does **not** + lodge your BAS or file a return. +- Before going live in AU: confirm threshold/liability, have your ABN, set + `tax_behavior` to match your pricing, and capture customer tax status for B2B. + +Verify all of the above against current ATO and Stripe Tax documentation — rates, +thresholds, and reverse-charge rules change. diff --git a/skills-public/stripe-startup-kit/sync_rails.py b/skills-public/stripe-startup-kit/sync_rails.py new file mode 100644 index 0000000..730bb7c --- /dev/null +++ b/skills-public/stripe-startup-kit/sync_rails.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Copy the canonical rails.py into every skill folder. + +rails.py is identical across the kit so each skill bundle stays self-contained +and publishable on its own. This is the single source of truth; run after any +edit to it. Verify-only with --check (exit 1 if any copy is stale) for CI. +""" + +import filecmp +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "rails.py" +SKILLS = [ + "stripe-stand-up", "stripe-product-to-price", "stripe-tax-ready", + "stripe-deliver", "stripe-revenue-read", +] + + +def main(): + check = "--check" in sys.argv + stale = [] + for name in SKILLS: + dst = ROOT / name / "rails.py" + if check: + if not dst.exists() or not filecmp.cmp(SRC, dst, shallow=False): + stale.append(name) + else: + shutil.copyfile(SRC, dst) + print(f"synced -> {name}/rails.py") + if check: + if stale: + print("STALE rails.py in: " + ", ".join(stale)) + return 1 + print("all rails.py copies are in sync") + return 0 + + +if __name__ == "__main__": + sys.exit(main())