Skip to content

pscale-commons/ticketing-agent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ticketing-agent

Reference issuer + verifier for the bsp-mcp payway convention.

A small Node/Hono service that:

  1. Takes payment for participation in a sed: collective.
  2. Issues a ticket grain on the substrate via pscale_grain_reach.
  3. 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.

What's in the box

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

Stack

  • Node 22, TypeScript, ES modules
  • Hono on @hono/node-server
  • better-sqlite3 for purchase records, idempotency keys, refund tracking, and verifier decision history
  • pino with secret redaction
  • zod for env + YAML validation
  • tweetnacl for Ed25519 signature verification (gift driver)
  • stripe for 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.

Three-command install

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 /.

Configuration

Two files — both are validated at startup; bad values fail loud rather than silently.

.env

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

config/agent.yaml

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-cast

Endpoints

Public

  • GET / — product catalogue (HTML; Accept: application/json for 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 over ticket-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 }.
  • 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.

Admin (require Authorization: Bearer <ADMIN_TOKEN>)

  • 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).

Driver flows

Stripe

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.

Gift

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.

Manual

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.

Verifier daemon

Runs in-process. On each tick (default every 5 seconds):

  1. Process collective: for each watched sed: collective with a _tickets field, walk its registrations, find positions whose sub-position 1 is a grain:<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, no credits= field, etc. Write [ticket-verified] or [ticket-rejected reason=...] to the audit collective.
  2. Revocation sweep: for each previously-verified row whose grain now carries a [ticket-revoked] envelope, write a fresh [ticket-rejected reason=revoked].
  3. Expiry sweep: for each previously-verified row whose expires_at has 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.

Storage shape

SQLite at data/purchases.sqlite. Two tables:

  • purchases — fiat-side state only. Pending → paid/rate_limited/failed/refunded transitions; idempotency key for webhooks; Stripe driver_ref for refund lookup; gift purchases use the nonce as driver_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 new rejected row.

Ticket truth lives on the substrate, not in SQLite. The local DB is purely for fiat-side bookkeeping.

Running tests

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_id

The 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.

Deploying

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 / etc

You'll need:

  • A pscale agent_id and matching TICKET_AGENT_SECRET.
  • A reachable bsp-mcp endpoint (config.agent.pscale_mcp_url).
  • Your _tickets field added to each sed: collective you're issuing for, pointing at this agent and your purchase_url.
  • Stripe keys (test mode is fine to start).
  • A ADMIN_TOKEN (long random string).
  • HTTPS in front (Stripe webhooks require it for production).

Federation

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).

License

MIT. See LICENSE.

About

Reference issuer + verifier for the bsp-mcp payway convention — a thin Node/Hono + SQLite daemon between a payment processor and the pscale federated beach.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors