{"id":"01284cc3-b58e-48c5-baf6-327269a37474","slug":"pretix-agent-mcp","kind":"mcp","name":"pretix-agent-mcp","summary":"MCP server that lets AI agents run a self-hosted pretix instance — without the API credential ever reaching the agent","owner":"bitcoinaustria","repoUrl":"https://github.com/bitcoinaustria/pretix-agent-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-12T11:04:11.000Z","addedAt":"2026-10-21T06:35:06.795Z","logoUrl":"https://avatars.githubusercontent.com/u/1807073?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["cloud-infra","memory-knowledge"],"tags":["ai-agents","pretix","python","ticketing"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T02:55:04.273Z","sample":false,"url":"https://conduits.dev/tools/pretix-agent-mcp","language":"Python","transports":["stdio"],"install":{"pypi":"pretix-agent-mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# pretix-agent-mcp\n\nRun a self-hosted [pretix](https://pretix.eu) instance from an AI agent — without the\npretix API token ever reaching the agent, and without giving it arbitrary REST access.\n\n```text\nCodex / AI agent          untrusted: may be manipulated via prompt injection\n        │\n        │ MCP streamable HTTP over Tailscale Serve + bearer   (or stdio for dev)\n        ▼\npretix-agent-mcp          trusted: holds the credential, enforces capabilities,\n        │                 masks personal data, gates irreversible writes\n        │ restricted pretix team token\n        ▼\npretix REST API\n```\n\n64 purpose-built tools cover routine event administration: create and configure events,\nclone last year's edition, manage series dates, products, quotas, prices, vouchers,\norders and check-in. The web UI is left for one-time organizer setup — notably payment\nprovider onboarding, which involves credentials an agent must never touch.\n\n> \"Clone this year's conference for 2027, June 12–14, same tickets, early-bird until\n> March.\" → the agent clones the event, adjusts dates and presale windows, and hands\n> back a preview link. The event exists in draft. Zero UI visits.\n>\n> \"Take the 2027 conference live.\" → the agent stages it; you run\n> `pretix-agent-mcp approve 3f9a1c` on the server. That's the whole ceremony.\n\n## What makes it safe\n\n| Threat | Mitigation |\n|---|---|\n| Prompt-injected agent exfiltrating personal data | `PII_MODE=redacted` by default: names, emails, addresses and phone numbers are masked in every result, and email addresses are scrubbed from pretix error bodies before they reach the agent. A deployment decision, never a tool parameter. |\n| Prompt-injected agent issuing destructive writes | Irreversible operations return a preview and a handle. A human approves them **out of band** on the server. A `confirm: true` parameter would just be set by the attacker. |\n| Prompt-injected agent redirecting money or mail | `update_event_settings` refuses payment routing (provider enablement, bank details, API keys) and mail routing (`mail_bcc`, `smtp_*`) outright. Neither is escalated to approval, because neither belongs to an agent at all — and pretix's own permission for payment settings is too coarse to make the distinction. |\n| Agent reaching beyond its remit | Capability allowlist; disabled tools are not advertised at all. The organizer slug is pinned in config. Every path segment is validated. **There is no generic HTTP/API tool.** |\n| Unauthorized network client | Bearer token required, constant-time compared. Binds `127.0.0.1` by default; refuses a non-localhost bind without a token. |\n| Credential theft | The pretix token lives only in the server's environment — never in results, errors, logs, audit records or tool parameters. |\n\nDefaults are closed: localhost, read-only, personal data masked, no writes. Each\nrelaxation is an explicit config change. See [SECURITY.md](SECURITY.md).\n\n## Install\n\n```bash\ngit clone https://github.com/bitcoinaustria/pretix-agent-mcp\ncd pretix-agent-mcp\nuv pip install -e .          # or: pip install -e .\ncp .env.example .env         # then edit it\n```\n\nRun it:\n\n```bash\nset -a && . ./.env && set +a\npretix-agent-mcp serve\n```\n\nOr with Docker:\n\n```bash\ndocker build -t pretix-agent-mcp .\ndocker run --rm -p 127.0.0.1:8765:8765 --env-file .env \\\n  -v \"$PWD/state:/state\" -e STATE_DB=/state/pending-actions.sqlite3 -e AUDIT_LOG=/state/audit.jsonl \\\n  -e MCP_HOST=0.0.0.0 pretix-agent-mcp\n```\n\nCheck what a configuration exposes before pointing an agent at it:\n\n```bash\npretix-agent-mcp tools\n```\n\n## Production deployment: Tailscale only\n\nThe supported production endpoint is `tailscale serve`: tailnet-only HTTPS in front of a\nlocalhost listener. The MCP bearer token stays required as a second authorization layer. Do\nnot expose this server directly, through Tailscale Funnel, or through a public reverse proxy:\nit has one static bearer token and deliberately has no OAuth, rate limiting, lockout, or\nanomaly detection.\n\nKeep a native server bound to its `127.0.0.1` default. With Docker, bind `0.0.0.0` only\ninside the container and publish it to host loopback, as the install command and\n[docker-compose.example.yml](docker-compose.example.yml) do. Then publish that loopback\nlistener to the tailnet:\n\n```bash\nsudo tailscale serve --bg --https=443 http://127.0.0.1:8765\n# → https://mcp-host.example-tailnet.ts.net/mcp\n```\n\nServe forwards the `ts.net` name in the `Host` header, so configure that exact MagicDNS\nhostname or every request is `421 Misdirected Request`:\n\n```bash\nMCP_ALLOWED_HOSTS=mcp-host.example-tailnet.ts.net\n```\n\nReplace the example with the machine's MagicDNS name. The supported production topology\nrequires that exact host. `*` remains a backwards-compatible escape hatch, but disables the\nSDK's DNS-rebinding protection and is not supported for production.\n\nIf a 421 does happen, the server log names the exact value it refused\n(`Invalid Host header: …`) — copy that into `MCP_ALLOWED_HOSTS` and restart the MCP server.\n\n**Approving over Tailscale SSH.** Enable Tailscale SSH once on the MCP host:\n\n```bash\nsudo tailscale set --ssh\n```\n\nThe [tailnet policy must allow both network and SSH\naccess](https://tailscale.com/docs/features/tailscale-ssh#ensure-tailscale-ssh-is-permitted-in-access-control-policies)\nfrom the approving identity to this host. Use an SSH rule with `action: check`. On plans\nthat support a [custom check\nperiod](https://tailscale.com/docs/reference/syntax/policy-file#checkperiod), set\n`checkPeriod: always` to require fresh identity-provider authentication for every approval\nconnection; otherwise Tailscale's default check period applies. Then approve from a laptop\nor phone without distributing SSH keys:\n\n```bash\nssh you@mcp-host.example-tailnet.ts.net \\\n  'cd /srv/pretix-agent-mcp && docker compose exec -T agent-mcp pretix-agent-mcp pending'\nssh you@mcp-host.example-tailnet.ts.net \\\n  'cd /srv/pretix-agent-mcp && docker compose exec -T agent-mcp pretix-agent-mcp approve 3f9a1c'\n```\n\nReplace the example hostname and `you` with your MagicDNS name and server user. For a native\ndeployment, run the same CLI commands after loading its server-side environment.\n\nThe bearer token remains mandatory behind Tailscale as a second authorization layer. Use\n`serve`, never `funnel`. A hosted agent that cannot join the tailnet needs a future\nOAuth-capable deployment; a public static-token endpoint is outside this project's supported\nproduction model.\n\n## Connecting a client\n\nEndpoint: `https://mcp-host.example-tailnet.ts.net/mcp` (streamable HTTP), with your\n`MCP_BEARER_TOKEN`. No bridge or proxy is needed — Codex and Claude Code both speak\nstreamable HTTP with a bearer token directly. Replace the example with your MagicDNS name.\n\n**Codex CLI** (`~/.codex/config.toml`):\n\n```toml\n[mcp_servers.pretix]\nurl = \"https://mcp-host.example-tailnet.ts.net/mcp\"\nbearer_token_env_var = \"PRETIX_MCP_TOKEN\"   # Codex stores the variable NAME, reads it at connect time\ntool_timeout_sec = 60                       # sales_summary can be slow on large events\n```\n\nThen `export PRETIX_MCP_TOKEN=...` in the shell that launches Codex, or use the CLI:\n\n```bash\ncodex mcp add pretix --url https://mcp-host.example-tailnet.ts.net/mcp --bearer-token-env-var PRETIX_MCP_TOKEN\n```\n\nGotchas: the key is `url` (there is no `http_url` and no `type = \"http\"` — transport is\ninferred); `bearer_token` is rejected at load in favour of `bearer_token_env_var`; static\nheaders go in `http_headers = { ... }` and cannot be set from `codex mcp add`.\n\n**Claude Code**:\n\n```bash\nclaude mcp add --transport http pretix https://mcp-host.example-tailnet.ts.net/mcp \\\n  --header \"Authorization: Bearer $PRETIX_MCP_TOKEN\"\n```\n\n**Same-machine development** uses stdio, where the parent process is the peer and no\nbearer token applies:\n\n```bash\npretix-agent-mcp serve --transport stdio\n```\n\n## pretix Hosted (pretix.eu) or self-hosted\n\nBoth work — it is the same documented REST API. For pretix Hosted set:\n\n```bash\nPRETIX_BASE_URL=https://pretix.eu\n```\n\nTwo hosted-specific things to know:\n\n- **Rate limits.** pretix Hosted allows 360 requests per minute per organizer for\n  token authentication and answers a 429 with `Retry-After`; self-hosted instances do not\n  rate-limit by default. The client honours `Retry-After` and retries up to three times,\n  so a burst degrades into a slower call rather than an error. `sales_summary` is the tool\n  most likely to hit the limit on a big event (one request per 100 orders scanned) — lower\n  `SALES_SCAN_CAP`, or ask pretix support for a higher limit, if you run into it.\n- **Plugins.** `set_event_plugins` can only enable what the instance actually ships;\n  hosted and self-hosted offer different plugin sets. Read the current list with\n  `get_event` first.\n\nNothing else differs: team tokens, the event and order APIs, and the settings API behave\nthe same, and the shop URLs the tools hand back are correct for either.\n\n## The pretix token\n\nCreate a **team** in pretix (Organizer → Teams) with only the permissions your enabled\ncapabilities need, then add a token to that team. Never use a team that can change teams\nand permissions.\n\npretix 2026.x names permissions `group:action`; older versions show the same things as\n*Can view orders*-style checkboxes. Both are listed below — the identifiers are what a\n2026.x instance stores, verified against a local 2026.7.\n\n| You enable | pretix team permission | older label |\n|---|---|---|\n| `read` | `event.orders:read`, `event.vouchers:read` | *Can view orders*, *Can view vouchers* |\n| `write` (events, settings) | `organizer.events:create`, `event.settings.general:write` | *Can create events*, *Can change event settings* |\n| `write` (catalog) | `event.items:write`, `event.settings.tax:write` | *Can change product settings* |\n| `write` (series dates) | `event.subevents:write` | *Can change event settings* |\n| `write` (vouchers) | `event.vouchers:write` | *Can change vouchers* |\n| `write` (orders: mark paid, comment, resend, edit attendee) | `event.orders:write` | *Can change orders* |\n| `write` (check-in lists) | `event.orders:checkin` | *Can change orders* |\n| `write:high-risk` (cancel, refund, delete) | `event.orders:write`, `event:cancel` | *Can change orders* |\n\nNever grant `organizer.teams:write` (*Can change teams and permissions*) — that is the one\npermission that would let a prompt-injected agent widen its own access. Do not grant\n`event.settings.payment:write` either unless you accept an agent editing payment\nconfiguration; nothing in the tool surface needs it.\n\nRestrict the team to specific events where you can, and set `PRETIX_EVENT_ALLOWLIST` as a\nsecond, server-side limit.\n\n## Capabilities\n\nEvery tool belongs to exactly one class. `MCP_CAPABILITIES` decides which classes exist;\nunlisted tools are never advertised to the client.\n\n| Class | Behaviour |\n|---|---|\n| `read` | Executes directly. |\n| `write` | Executes directly when enabled. |\n| `write:high-risk` | Records a pending action and returns a preview. Mutates nothing until a human approves. |\n\n**The live-event guard.** A `write` that targets a **live** event and can affect price,\navailability, product structure or existing orders is escalated to `write:high-risk` at\ncall time. The same operation on a draft or test-mode event executes directly. So agents\nbuild and reconfigure drafts without friction, while anything touching a selling event or\na customer's money goes through approval.\n\n`publish_event` is always high-risk — it is the boundary crossing itself. It sets\n`live=true` **and** leaves test mode, because test mode, not the live flag, is what keeps\norders from being real. No other tool can leave test mode, so an agent cannot start real\nsales without an approval.\n\nAn operator who accepts the risk can reclassify individual high-risk tools to plain\n`write` with `MCP_AUTO_APPROVE` (e.g. auto-approve `publish_event`, keep the gate on\nrefunds). That is an explicit, audited, per-tool decision.\n\n## The approval ceremony\n\n```console\n$ pretix-agent-mcp pending\n\n3f9a1c  publish_event  (expires in 812s)\n    Take event 'conf27' (Conf 27) LIVE — the public shop opens.\n      starts:  2027-06-12T09:00:00+02:00\n      presale: 2027-01-15T00:00:00+01:00 → 2027-06-01T00:00:00+02:00\n      test mode: True\n\napprove with: pretix-agent-mcp approve <id>   (1 pending)\n\n$ pretix-agent-mcp approve 3f9a1c\napproved 3f9a1c (publish_event)\nthe agent can now call execute_pending_action with this id\n```\n\n`approve --run` executes it immediately instead of waiting for the agent. `reject <id>`\nthrows it away. Nothing here is reachable from the agent: the approval lives on the\nserver on purpose, because a chat-based confirmation is forgeable by a prompt-injected\nagent and a shell command on the server is not.\n\n## Tools\n\n| Domain | read | write | write:high-risk |\n|---|---|---|---|\n| Events | `list_events`, `get_event`, `get_event_settings`, `list_tax_rules` | `create_event`, `clone_event`, `update_event`¹, `update_event_settings`¹, `set_event_plugins`¹, `unpublish_event`¹, `create_tax_rule`¹, `update_tax_rule`¹ | `publish_event`, `delete_event`, `delete_tax_rule` |\n| Series dates | `list_subevents`, `get_subevent` | `create_subevents`¹, `update_subevent`¹ | `delete_subevent` |\n| Catalog | `list_products`, `get_product`, `list_quotas`, `get_availability`, `list_categories`, `list_questions` | `create_product`¹, `update_product`¹, `create_product_variation`¹, `update_product_variation`¹, `create_category`¹, `update_category`¹, `create_quota`¹, `update_quota`¹, `create_question`¹, `update_question`¹ | `delete_product`, `delete_quota`, `delete_category`, `delete_question` |\n| Orders | `search_orders`, `get_order`, `search_attendees`, `sales_summary` | `mark_order_paid`¹, `extend_payment_deadline`¹, `add_order_comment`, `edit_attendee`¹, `resend_order_email` | `cancel_order`, `refund_order` |\n| Vouchers | `list_vouchers` | `create_voucher`, `create_vouchers_batch`¹ | `delete_voucher` |\n| Check-in & waiting list | `list_checkin_lists`, `list_checkins`, `list_waiting_list` | `create_checkin_list`, `update_checkin_list`, `send_waiting_list_voucher` | `delete_checkin_list` |\n| Approval | `get_pending_action` | `execute_pending_action` | — |\n\n¹ subject to the live-event guard. A single voucher is an ordinary write; a bulk batch is\nguarded, because 500 free or quota-blocking vouchers against a selling event is a price and\navailability change in all but name (a documented tightening of the PRD, which classed all\nvoucher tools as plain `write`).\n\nBe clear-eyed about what that guard is and is not: it makes the *bulk* path visible, and an\nagent that calls `create_voucher` in a loop reaches the same place without an approval. The\nguard is not a rate limit, and no per-call gate can be one. Granting `write` on a live event\nmeans trusting the agent with unlimited single vouchers; the audit log is what makes that\nreviewable afterwards. Deployments that do not want the trade-off keep the voucher tools out\nof `MCP_TOOL_ALLOWLIST`, or add `create_voucher` to a high-risk classification of their own.\n\nNotes:\n\n- `sales_summary` is computed server-side by paginating orders (pretix has no aggregate\n  endpoint). It returns totals and counts only, in either PII mode, and reports its scan\n  window — large events are slow and the window is capped (`SALES_SCAN_CAP`).\n- All list tools paginate, cap results server-side (≤ 50 per page by default) and mark a\n  truncated answer as such.\n- `create_subevents` is a batch: \"every first Thursday until December, 40 seats each\" is\n  one call. The agent computes the dates; the server creates them.\n\n## Configuration\n\nEnvironment variables, or a JSON config file passed with `--config` (env wins). Full list\nwith comments in [.env.example](.env.example).\n\n| Variable | Default | Meaning |\n|---|---|---|\n| `PRETIX_BASE_URL` | — | Base URL of your pretix instance |\n| `PRETIX_API_TOKEN` | — | Restricted team token. Server-side only |\n| `PRETIX_ORGANIZER` | — | Organizer slug, pinned; agents cannot change it |\n| `PRETIX_EVENT_ALLOWLIST` | all events | Comma-separated event slugs |\n| `MCP_BEARER_TOKEN` | — | Client credential (≥ 24 chars). Required for HTTP |\n| `MCP_ALLOWED_HOSTS` | bind address | Extra `Host` values to accept — set the Tailscale MagicDNS hostname for production |\n| `MCP_CAPABILITIES` | `read` | `read`, `write`, `write:high-risk` |\n| `MCP_TOOL_ALLOWLIST` | — | Expose exactly these tools |\n| `MCP_AUTO_APPROVE` | — | High-risk tools reclassified to `write` |\n| `PII_MODE` | `redacted` | `redacted` or `full` |\n| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8765` | Bind address |\n| `AUDIT_LOG` | `audit.jsonl` | Append-only audit log |\n| `STATE_DB` | `pending-actions.sqlite3` | Pending approvals |\n| `APPROVAL_TTL_SECONDS` | `900` | How long a proposal stays approvable |\n| `SALES_SCAN_CAP` | `5000` | `sales_summary` scan window |\n\n## Running alongside pretix\n\n[docker-compose.example.yml](docker-compose.example.yml) runs this as its **own container**\nnext to `pretix/standalone`, reaching pretix over the internal network\n(`PRETIX_BASE_URL=http://pretix:80`).\n\nDo not extend the pretix image to run both in one container, even though `pip install\npretix-agent-mcp` into a derived image would work. The pretix container holds the database\ncredentials, the Django secret key and the ticket data; this one holds an API token limited\nto a restricted team. Separating them is what keeps a compromise of the agent-facing\nservice — the component deliberately exposed to an untrusted agent — from becoming a\ncompromise of the whole instance, which is the difference between \"the attacker can do what\nthat token allows\" and \"the attacker reads your database\". It also keeps `docker pull` an\nupgrade instead of an image rebuild, and keeps pretix's own supervisor config untouched.\n\nFor the same reason, this is deliberately not a pretix plugin. Plugin code runs inside the\nticketing application with its database credentials and secrets; the sidecar limits a\ncompromise to the restricted team's API permissions and keeps the documented REST API as\nthe compatibility seam. A future optional plugin may add operator UI, but must not host the\nMCP tools or approval authority.\n\nTwo things to keep in mind for that setup:\n\n- Mount a volume for `/state`. Without it, an approved-but-not-executed action and the\n  whole audit trail vanish on restart.\n- `docker exec` into the MCP container is enough to approve a pending action, so access to\n  the Docker socket is equivalent to approval rights. That is the same trust level as shell\n  access on the host, which is what the out-of-band approval assumes.\n\nRun the server on a different machine from the agent when you can — that is what makes\n\"the credential is not on the agent's machine\" a structural property rather than a habit.\n\n## Protocol\n\nMCP revision **2026-07-28** via the official Python SDK, which keeps backward\ncompatibility with the initialization-based revisions (2025-06-18 / 2025-11-25). The\n2026-07-28 protocol is stateless: no `initialize` handshake, no session id, protocol\nversion per request. `server/discover` and the `tools/list` freshness hints\n(`ttlMs`/`cacheScope`) come from the SDK; cross-call state — pending-action ids — is a\nserver-minted handle passed as an ordinary tool argument.\n\nAuthorization deviates from the spec's OAuth 2.1 framework (which is optional): a static\nbearer token, constant-time compared, appropriate for a single-operator deployment where\nthe operator controls both ends. The spec's token rules still hold — header only, never\nthe query string; missing or invalid token gets 401. Multi-user access would mean\nimplementing RFC 9728 metadata and an external authorization server; that is not built.\n\nDeprecated MCP features are deliberately absent: Roots, Sampling, Logging, HTTP+SSE.\nDiagnostics go to stderr; writes go to the audit log.\n\n## Audit log\n\nOne JSONL record per write and per high-risk lifecycle event (proposed / approved /\nexecuted / failed), with the tool, PII-redacted arguments, outcome and pending-action id.\nReads are not logged. Neither token ever appears.\n\n```json\n{\"ts\":\"2027-01-15T10:22:31Z\",\"event\":\"executed\",\"tool\":\"publish_event\",\"outcome\":\"ok\",\"pending_action_id\":\"3f9a1c\",\"args\":{\"event\":\"conf27\"}}\n```\n\n## Known limits\n\nThese are pretix API limits, not workarounds waiting to happen:\n\n- **Payment provider onboarding** (Stripe/PayPal credentials, OAuth connects) is\n  organizer-level, partly outside the REST API, and involves credentials agents must not\n  handle. One-time UI task.\n- **Event settings** expose the subset the pretix settings API exposes. Where the UI has\n  more, the tools have less.\n- **Mail routing settings** (`mail_bcc`, `mail_from`, `mail_reply_to`, `smtp_*`) are\n  refused by `update_event_settings` on purpose: an agent that can BCC every customer mail\n  to an address of its choosing exfiltrates personal data that redaction never sees,\n  because it never passes through the agent's context. Change those in the UI.\n- **Question options** are not editable via PATCH on a question; recreate the question,\n  or edit choices in the UI.\n- **Payment is configured once, by you, at organizer level.** pretix refuses `live=true`\n  while any product costs money and no payment provider is enabled. Set the provider up on\n  the *organizer* (its plugin, then its account details) and every event inherits it —\n  including the ones an agent creates later, so paid events publish without anyone opening\n  the web UI again. `publish_event`'s preview lists the products, priced products and quotas\n  it can see, plus a reminder about this precondition; it deliberately does **not** claim\n  which providers are enabled, because pretix's settings API exposes core settings only and\n  a correctly configured bank transfer or Stripe is invisible there. A free event (a meetup,\n  a stammtisch) needs none of this.\n- **`set_event_plugins` can only enable plugins the organizer already allows.** Allowing a\n  new plugin is an organizer-level operator task, deliberately outside the tool surface.\n- **Payment settings are not agent-writable, and that is enforced here rather than by\n  pretix.** pretix has one coarse permission (`event.settings.payment:write`) covering\n  payment deadlines and destination IBANs alike, so an operator who wants an agent to set a\n  deadline would otherwise have to grant it the ability to redirect every payment.\n  `update_event_settings` refuses the money-routing subset — provider enablement, bank\n  details, anything whose name looks like a credential or an account — while letting\n  `payment_term_*` through. Third-party providers (BTCPay, Stripe, PayPal) need no special\n  handling: the refusal is keyed on the shape of the setting name, not a list of providers.\n- **Amounts take at most two decimal places** and must be decimal strings — a float is\n  refused rather than rounded, because a rounded price is a rounding bug charged to a\n  customer. Currencies with three decimal places (KWD, BHD, TND) are therefore not\n  supported; widen `PRICE_RE` in [validate.py](pretix_agent_mcp/validate.py) if you\n  need one.\n- **Checking someone in** is deliberately not a tool: scanning tickets is a\n  physical-presence operation.\n- Organizer-level team and token management is out of scope by design — agents must not\n  manage their own permissions.\n\n## Development\n\n```bash\nmake setup\nmake ci      # ruff check + format check + pytest, the same thing CI runs\n```\n\n- [AGENTS.md](AGENTS.md) — the working rules (and what Claude, Codex and friends load)\n- [ARCHITECTURE.md](ARCHITECTURE.md) — where the seams are and why\n- [TESTING.md](TESTING.md) — the fake-pretix harness and what a change owes in tests\n- [CONTRIBUTING.md](CONTRIBUTING.md) — how to open a PR\n- [PRD.md](PRD.md) — the product spec this was built from\n\nMIT licensed. Contributions welcome — changes to validation, redaction, the approval gate\nor auth need tests.\n"}