Reference issuer + verifier for the bsp-mcp payway convention.
A small Node/Hono service that:
- Takes payment for participation in a
sed:collective. - Issues a ticket grain on the substrate via
pscale_grain_reach. - Runs a verifier that walks each watched collective every few seconds and writes
[ticket-verified | ticket-rejected | ticket-expired]envelopes into a public audit log on its own beach.
Forkable in an afternoon. The whole thing is ~2k lines of TypeScript over a small SQLite database. The substrate (bsp-mcp) does the load-bearing work; this agent is a thin layer between Stripe and the beach.
Federation guarantees (§6 of the protocol): no central issuer, no central client, no protocol-level fees. Anyone can stand up their own deployment; a frame-owner switches issuers in a five-minute migration.
| Capability | Status | Where |
|---|---|---|
| HTTP + config + SQLite skeleton | ✅ M0 | src/index.ts, src/server.ts, src/lib/{env,log,config,db}.ts |
| Envelope grammar + grain helpers (establish, walk, revoke) | ✅ M1 | src/lib/{envelope,grain,pscale}.ts |
| Public catalogue page | ✅ M2 | src/routes/catalogue.ts |
| Stripe Checkout, webhook, idempotent issuance, rate-limit caps | ✅ M3 | src/drivers/stripe.ts, src/routes/{purchase,webhook}.ts, src/lib/rate-limit.ts |
| Verifier daemon, eight-rule check, public audit log, expiry sweep, revocation sweep | ✅ M4 / M5 | src/lib/{verifier,audit}.ts |
| Refunds: Stripe refund + grain revoke | ✅ M5 | src/routes/admin.ts |
| Gift driver (Ed25519-signed redemption) | ✅ M6 | src/drivers/gift.ts |
| Manual driver (bank transfer + admin mark-paid) | ✅ M6 | src/drivers/manual.ts |
- Node 22, TypeScript, ES modules
- Hono on
@hono/node-server better-sqlite3for purchase records, idempotency keys, refund tracking, and verifier decision historypinowith secret redactionzodfor env + YAML validationtweetnaclfor Ed25519 signature verification (gift driver)stripefor Checkout Session + webhooks (Stripe driver)
The substrate connection is a tiny in-tree HTTP MCP client over bsp-mcp's Streamable HTTP transport — no MCP SDK dependency.
git clone <this-repo> ticketing-agent && cd ticketing-agent
npm install
cp .env.example .env && cp config/agent.yaml.example config/agent.yaml
# ── edit .env and config/agent.yaml ──
npm run dev/health should return your agent identity. The catalogue is at /.
Two files — both are validated at startup; bad values fail loud rather than silently.
| Variable | Required for | Notes |
|---|---|---|
TICKET_AGENT_SECRET |
All flows | This is your pscale agent secret. Used as the HMAC key for per-grain passphrase derivation; rotating it invalidates revocation authority on previously-issued grains, so do this carefully. |
ADMIN_TOKEN |
All flows | Bearer token gating /admin/* endpoints. |
STRIPE_SECRET_KEY |
Stripe products | Otherwise leave unset. |
STRIPE_WEBHOOK_SECRET |
Stripe products | Webhook signature verification is non-negotiable. |
RATE_LIMIT_WEBHOOK |
Optional | If set, POSTed when a per-product cap is hit at issuance. |
PORT |
Optional, defaults 8080 |
|
PUBLIC_URL |
Optional, defaults http://localhost:8080 |
Used in success_url / cancel_url for Stripe and in catalogue links. |
LOG_LEVEL |
Optional, defaults info |
trace-fatal. |
PURCHASES_DB_PATH |
Optional, defaults ./data/purchases.sqlite |
Declares the agent's identity, the bsp-mcp endpoint it talks to, the products it sells, and which collectives the verifier watches. See config/agent.yaml.example for a full example with all three driver kinds.
agent:
id: agent:my-tickets # any unique pscale agent_id
secret_env: TICKET_AGENT_SECRET
pscale_mcp_url: https://bsp.hermitcrab.me/mcp/v1
products:
- id: character-30d
sed: sed:my-frame-cast
face: character # character | author | designer
scope: frame:my-frame # exact, prefix-pattern (frame:foo-*), or beach:X
duration_days: 30
rate_limit:
max_per_hour: 100
max_per_day: 500
price:
driver: stripe
stripe_price_id: price_XXX
description: "Play a character in my frame for 30 days"
verifier:
poll_interval_seconds: 5
# Defaults to the union of products[].sed if omitted.
# watch:
# - sed:my-frame-castGET /— product catalogue (HTML;Accept: application/jsonfor the JSON form).GET /buy/:product_id— per-driver buy form.POST /buy/:product_id— start a purchase.- Stripe:
{ buyer_agent_id }→ returns{ checkout_url, purchase_id }(or 303 redirect for form posts). - Gift:
{ buyer_agent_id, gifter_agent_id, issued_at, nonce, signature }. Signature is base64 Ed25519 overticket-gift:<product_id>:<buyer_agent_id>:<issued_at>:<nonce>. Returns{ ok, purchase_id, pair_id }on success. - Manual:
{ buyer_agent_id }→ returns{ purchase_id, status: "pending", instructions, reference }.
- Stripe:
GET /buy/:product_id/{success,cancel}— minimal landing pages.POST /webhook/stripe— Stripe Checkout Session webhook. Signature-verified, idempotent, rate-limited at issuance time.GET /health— agent identity + product count.
GET /admin/purchases[?status=...]— list purchases.GET /admin/purchases/:id— single row.GET /admin/rate-limit/:product_id— current rate-limit decision for a product.POST /admin/refund/:id— Stripe refund + grain revoke. Body:{ reason }(no whitespace). The verifier picks up the revocation on its next tick and writes a[ticket-rejected reason=revoked]audit entry.POST /admin/mark-paid/:id— operator confirms a manual (bank transfer) purchase has cleared. Issues the grain and marks the row paid. Body:{ notes }(optional).
product.price.driver: stripe with stripe_price_id. Buyer hits POST /buy/:product_id with their agent_id; we create a Checkout Session with metadata that round-trips the purchase id and redirect them. Stripe's checkout.session.completed webhook signature-verifies, looks up the purchase, runs the authoritative rate-limit check, derives the per-grain passphrase from TICKET_AGENT_SECRET, and calls pscale_grain_reach on the buyer's agent_id. The grain lands; the row is marked paid.
Refund: POST /admin/refund/:id with { reason }. Calls stripe.refunds.create then writes a [ticket-revoked] envelope to <issuer-side>.1 of the grain. The verifier picks this up on the next tick.
product.price.driver: gift with gifters: [agent:host, ...]. The gifter has previously run pscale_key_publish so their public Ed25519 key sits at passport:9.ed25519. They sign the canonical message ticket-gift:<product_id>:<buyer_agent_id>:<issued_at>:<nonce> and hand the buyer the result. The buyer (or an automated client) POSTs to /buy/:product_id with the signed bundle. We fetch the gifter's pubkey, verify the signature, and issue the grain inline — no payment processor in the loop.
The 24-hour issued_at window prevents replay of stale signatures; the nonce gives single-use semantics within the window.
product.price.driver: manual with optional instructions. POST /buy/:product_id returns the instructions text and a reference (the purchase id). The buyer sends payment out of band; the operator reconciles their bank statement and calls POST /admin/mark-paid/:purchase_id. The grain is issued at that moment.
Runs in-process. On each tick (default every 5 seconds):
- Process collective: for each watched
sed:collective with a_ticketsfield, walk its registrations, find positions whose sub-position 1 is agrain:<pair_id>:<side>reference, and apply the eight-rule check from §2.4 of the protocol — face match, scope compat, expires in future, no revocation, issuer matches_tickets.issuer, nocredits=field, etc. Write[ticket-verified]or[ticket-rejected reason=...]to the audit collective. - Revocation sweep: for each previously-verified row whose grain now carries a
[ticket-revoked]envelope, write a fresh[ticket-rejected reason=revoked]. - Expiry sweep: for each previously-verified row whose
expires_athas passed, write[ticket-expired].
The audit collective is sed:<verifier-bare-id>-audit-<yyyy-mm> on the agent's beach. One per calendar month. Each entry's underscore is the verifier envelope, with registration=<sed:...> and grain=<grain:...> extension fields so external readers can correlate.
SQLite at data/purchases.sqlite. Two tables:
purchases— fiat-side state only. Pending → paid/rate_limited/failed/refunded transitions; idempotency key for webhooks; Stripedriver_reffor refund lookup;giftpurchases use the nonce asdriver_ref.verifier_decisions— local tracking of which(collective, position, decision)combinations have been written to the audit log. UNIQUE on the triple; a verified→revoked transition writes a newrejectedrow.
Ticket truth lives on the substrate, not in SQLite. The local DB is purely for fiat-side bookkeeping.
npm run typecheck
npm test # 110 unit tests, all in-memory
npx tsx scripts/m1-roundtrip.ts # live round-trip against bsp.hermitcrab.me
# — uses an ephemeral dev agent_idThe unit tests cover envelope round-trips, the eight verifier rules, all driver flows (Stripe / gift / manual), refund + revocation paths, audit log appends, idempotency, and rate-limit windows. They run against an in-memory fake bsp-mcp so npm test never hits the network.
The M1 round-trip script is the integration smoke — establishes a grain, walks it, revokes, walks again — against a live bsp-mcp endpoint.
Three commands on a single VPS:
git clone <this-repo> /opt/ticketing-agent
cd /opt/ticketing-agent && npm install --production && npm run build
node dist/index.js # add a systemd unit / pm2 / etcYou'll need:
- A pscale
agent_idand matchingTICKET_AGENT_SECRET. - A reachable bsp-mcp endpoint (
config.agent.pscale_mcp_url). - Your
_ticketsfield added to eachsed:collective you're issuing for, pointing at this agent and yourpurchase_url. - Stripe keys (test mode is fine to start).
- A
ADMIN_TOKEN(long random string). - HTTPS in front (Stripe webhooks require it for production).
A frame-owner switching from this issuer to another one updates _tickets.issuer and _tickets.purchase_url on their collective. Existing live grains stop being honoured by the new verifier (because they're from the old issuer); new purchases use the new path. Five-minute migration. Nothing about this agent's deployment encodes anything that couples a collective to it.
If you fork this repo for a multi-tenant SaaS, KYC integrations, or analytics, please keep that in your fork — the reference stays small (§6.3 of the protocol).
MIT. See LICENSE.