From ec48d2a9ba9faadfd60da919e60fa1d7c9370ced Mon Sep 17 00:00:00 2001 From: biast12 Date: Sat, 25 Apr 2026 21:38:12 +0200 Subject: [PATCH 1/4] Add TicketsBot integration standards Introduce INTEGRATION_STANDARDS.md as the authoritative guide for public integrations hosted by TicketsBot. The document defines mandatory requirements (Authorization guard, POST-only method enforcement, Sentry setup, per-guild secret headers, /validate endpoint, and caching via INTEGRATION_CACHE), response and error formats, wrangler.toml conventions, and recommended practices (jsonResponse helper, input validation, secret format checks). Intended to ensure consistency and security across all integrations in the repository. --- INTEGRATION_STANDARDS.md | 339 +++++++++++++++++++++++++++++++++++++++ README.md | 27 +--- fivem/index.js | 2 +- 3 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 INTEGRATION_STANDARDS.md diff --git a/INTEGRATION_STANDARDS.md b/INTEGRATION_STANDARDS.md new file mode 100644 index 0000000..db00d00 --- /dev/null +++ b/INTEGRATION_STANDARDS.md @@ -0,0 +1,339 @@ +# TicketsBot Integration Standards + +This document is the authoritative reference for building **public integrations** that are accepted into this repository and hosted by TicketsBot. Every integration in this repo **must** follow these standards. + +**This does not apply to normal (self-hosted / third-party) integrations.** Those only need to comply with the [Privacy Policy](https://tickets.bot/privacy) and [Terms of Service](https://tickets.bot/terms-of-service). The standards and practices in this document are still a useful guide for anyone building their own integration well. + +--- + +## How Integrations Work + +When a ticket is opened, TicketsBot POSTs to the worker's root URL with a JSON body: + +```json +{ + "guild_id": "123456789", + "user_id": "987654321", + "ticket_id": "42", + "ticket_channel_id": "111222333", + "is_new_ticket": true +} +``` + +All fields are always present. + +The worker returns a flat or nested JSON object. TicketsBot maps response fields to ticket placeholder variables using dot-path notation (e.g. a field `user.username` becomes the placeholder `{user.username}`). **Arrays are not supported** — pre-join them to strings before returning. + +The `Authorization` header and all configured integration headers (including per-guild secret placeholders) are injected by TicketsBot's backend before the request reaches the worker. The worker never reads secrets from query parameters or the POST body. + +--- + +## Required Standards + +### 1. Authorization Guard + +Every worker **must** check the `Authorization` request header against a static worker secret before doing anything else. Return `401` on mismatch. This proves the caller is TicketsBot. + +The secret **must** be provisioned with `wrangler secret put` (stored in Cloudflare) and is accessed at runtime via the `env` parameter as `_AUTH_KEY`. + +```js +async function handleRequest(request, env) { + if (request.headers.get("Authorization") !== env.MYINTEGRATION_AUTH_KEY) { + return jsonResponse({ error: "Invalid auth key" }, { status: 401 }); + } + // ... +} +``` + +The auth check **must** be the very first thing in `handleRequest`, before method enforcement or routing. + +--- + +### 2. Method Enforcement + +After the auth guard passes, every worker **must** reject non-`POST` requests with `405`. + +```js +if (request.method !== "POST") { + return jsonResponse({ error: "Method Not Allowed" }, { status: 405 }); +} +``` + +--- + +### 3. Sentry + +Every worker **must** wrap its `fetch` handler with `Sentry.withSentry` from `@sentry/cloudflare`. The required configuration: + +| Field | Value | +|-------|-------| + +| `dsn` | `env.SENTRY_DSN` | +| `tracesSampleRate` | `1.0` | +| `sendDefaultPii` | `true` | + +```js +import * as Sentry from "@sentry/cloudflare"; + +export default Sentry.withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(request, env) { + return handleRequest(request, env); + }, + }, +); +``` + +`SENTRY_DSN` **must** be set as a `[vars]` entry in `wrangler.toml` (not a Cloudflare secret — it is not sensitive): + +```toml +[vars] +SENTRY_DSN = "https://@sentry.tkts.bot/" +``` + +`withSentry` captures unhandled errors automatically. Do **not** add a redundant top-level `try/catch` solely for logging. + +--- + +### 4. Secrets via Request Headers + +Per-guild secrets (API keys, server IDs, tokens) **must** be passed as named request headers, not as query parameters or in the POST body. + +Header names **must** follow the pattern `X--` (title-case, hyphen-separated). Examples: + +- `X-Bloxlink-Api-Key` +- `X-FiveM-Server-Id` + +These headers are configured in the TicketsBot dashboard using `%secret_name%` placeholder syntax that resolves to per-guild values at call time. Document all integration headers in `wrangler.toml` comments (see §8). + +```js +const apiKey = request.headers.get("X-Myintegration-Api-Key"); +if (!apiKey) { + return jsonResponse({ error: "Missing X-Myintegration-Api-Key header" }, { status: 400 }); +} +``` + +--- + +### 5. `/validate` Endpoint + +Every worker that uses per-guild secrets **must** implement a `/validate` endpoint. TicketsBot POSTs to this endpoint when a guild admin activates the integration; the secret headers are present on this request with the values the admin supplied. + +`/validate` is **only** called during activation — not on every ticket open. + +Requirements: + +- Validate the format of all secret headers first (see §Good Practices). Return `400` with a user-readable `error` message on format failure. +- Where possible, make a live API call to confirm the secret works. Return `400` on failure (with a human-readable message), `500` if the upstream API is unexpectedly unavailable. +- Return `200 {}` on success. + +```js +async function handleValidate(request) { + const apiKey = request.headers.get("X-Myintegration-Api-Key"); + if (!apiKey) { + return jsonResponse({ error: "Missing X-Myintegration-Api-Key header" }, { status: 400 }); + } + if (!API_KEY_REGEX.test(apiKey)) { + return jsonResponse({ error: "Invalid API key format" }, { status: 400 }); + } + + const res = await fetch("https://api.myintegration.example/verify", { + headers: { Authorization: apiKey }, + }); + if (res.status === 401) { + return jsonResponse({ error: "API key is invalid or has been revoked" }, { status: 400 }); + } + if (!res.ok) { + return jsonResponse( + { error: `Upstream API responded with ${res.status} — it may be experiencing an outage` }, + { status: 500 }, + ); + } + + return jsonResponse({}); +} +``` + +Route to `/validate` before the default lookup handler: + +```js +const url = new URL(request.url); +if (url.pathname === "/validate") { + return handleValidate(request); +} +return handleLookup(request, env); +``` + +--- + +### 6. Caching with `INTEGRATION_CACHE` + +Caching is **not required**, but **must** be used whenever upstream data is reasonably stable across requests. + +Use the shared KV namespace binding `INTEGRATION_CACHE` (id `7901ae2b471145d4ab7b8535c158d892`). Declare it in `wrangler.toml`: + +```toml +[[kv_namespaces]] +binding = "INTEGRATION_CACHE" +id = "7901ae2b471145d4ab7b8535c158d892" +``` + +**Cache key format:** `::` + +Examples: + +- `bloxlink::` — scoped per guild because different guilds use different API keys +- `fivem::` — scoped per server + +**TTL guidance:** + +| Data type | `expirationTtl` | +|-----------|-----------------| + +| Slow-changing (profile data, account info) | `86400` (24 h) | +| Live / session data (online players) | `300` (5 min) | + +**Cache hit/miss header:** Lookup responses **must** include `x-from-cache: true` or `x-from-cache: false`. + +```js +const cacheKey = `myintegration:${scope}:${userId}`; +const cached = await env.INTEGRATION_CACHE.get(cacheKey); +if (cached !== null) { + return new Response(cached, { + status: 200, + headers: { "content-type": "application/json", "x-from-cache": "true" }, + }); +} + +// ... fetch from upstream ... + +const payload = JSON.stringify(result); +await env.INTEGRATION_CACHE.put(cacheKey, payload, { expirationTtl: CACHE_TTL_SECONDS }); +return new Response(payload, { + status: 200, + headers: { "content-type": "application/json", "x-from-cache": "false" }, +}); +``` + +--- + +### 7. Response Format + +- All responses **must** use `content-type: application/json`. +- Error responses **must** use `{ "error": "..." }` with a user-readable message. +- When the target user is not found / not linked in the upstream service, return `200 {}` (an empty object). This signals to TicketsBot that placeholders should resolve to their configured fallback values. **Do not return `404` for not-found users.** +- Success responses **should** be flat objects where possible. Nested objects are supported via dot-path placeholders, but arrays are not. + +| Condition | Status | Body | +|-----------|--------|------| + +| Success with data | `200` | `{ ...fields }` | +| User not found / not linked | `200` | `{}` | +| Bad request (missing field, invalid format) | `400` | `{ "error": "..." }` | +| Unauthorized (auth key mismatch) | `401` | `{ "error": "Invalid auth key" }` | +| Method not allowed | `405` | `{ "error": "Method Not Allowed" }` | +| Upstream API unavailable / unexpected error | `500` | `{ "error": "..." }` | + +--- + +### 8. `wrangler.toml` Conventions + +```toml +name = "" +main = "index.js" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat"] + +[observability.logs] +enabled = true + +[vars] +SENTRY_DSN = "https://@sentry.tkts.bot/" + +[[kv_namespaces]] +binding = "INTEGRATION_CACHE" +id = "fbdf23642f6a40d0b5876abf3265910d" + +# Secrets (set via `wrangler secret put `): +# _AUTH_KEY — static guard token; callers must send this in the Authorization header +# +# Integration request headers (configured in the dashboard): +# Authorization: <_AUTH_KEY value> (static, proves the caller is TicketsBot) +# X--: %% (per-guild secret; guild admin provides on activation) +``` + +Required fields: `compatibility_date`, `compatibility_flags`, `[observability.logs]`, `SENTRY_DSN` var, `INTEGRATION_CACHE` KV binding, and commented documentation of all secrets and integration headers. + +--- + +## Good Practices + +### `jsonResponse` Helper + +Every worker **should** define a `jsonResponse` helper to avoid constructing `Response` objects inline: + +```js +function jsonResponse(body, { status = 200 } = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} +``` + +For the common empty-object response, an `emptyResponse` alias is a useful convenience: + +```js +const emptyResponse = () => jsonResponse({}); +``` + +### Input Validation + +Every lookup handler **must** validate `user_id` from the POST body, and any other body fields the integration relies on. Return `400` on failure. Always parse the body defensively: + +```js +let body; +try { + body = await request.json(); +} catch { + return jsonResponse({ error: "Invalid request body" }, { status: 400 }); +} + +const { user_id: userId } = body; +if (!userId) { + return jsonResponse({ error: "Invalid request body" }, { status: 400 }); +} +``` + +If the integration is scoped by guild (e.g. uses `guild_id` for upstream API calls or as a cache key component), validate it too: + +```js +const { guild_id: guildId, user_id: userId } = body; +if (!guildId || !userId) { + return jsonResponse({ error: "Invalid request body" }, { status: 400 }); +} +``` + +### Secret Format Validation Before Live Calls + +In `/validate`, always check secret format with a regex or length constraint **before** making any live API call. This gives the user a fast, specific error message and avoids unnecessary upstream requests. + +```js +const API_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +if (!API_KEY_REGEX.test(apiKey)) { + return jsonResponse({ error: "Invalid API key format (expected UUID v4)" }, { status: 400 }); +} +// Only reach the live API call if format is valid +``` + +### Sentry and Error Handling + +`Sentry.withSentry` captures any unhandled exception thrown from the `fetch` handler and reports it to Sentry automatically. Do **not** add a top-level `try/catch` around `handleRequest` just for error logging — it is redundant and suppresses Sentry's stack-trace capture. + +Handle only the errors you can meaningfully recover from inline. Let everything else propagate. diff --git a/README.md b/README.md index 127d7fa..38949ec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ - - [![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] @@ -16,7 +14,7 @@

Tickets Bot - Integrations

- Cloudflare Workers powering third-party integrations for Tickets — the simple, customisable and powerful Discord ticket system. + Cloudflare Workers powering public integrations for Tickets — the simple, customisable and powerful Discord ticket system.
Explore the docs »
@@ -50,29 +48,24 @@ ## About The Project -This repository contains the Cloudflare Workers that power Tickets' third-party integrations. Each folder is an independent Worker with its own `wrangler.toml` and `package.json`, deployed via a shared GitHub Actions workflow. +This repository contains the Cloudflare Workers that power Tickets' public integrations. Each folder is an independent Worker with its own `wrangler.toml` and `package.json`, deployed via a shared GitHub Actions workflow. The `proxy` Worker sits in front of the others: callers authenticate against the proxy once, and the proxy forwards matching requests to sibling Workers via service bindings so traffic stays on Cloudflare's network rather than egressing via the public internet. -

(back to top)

- ### Built With * [![Cloudflare Workers][Workers]][Workers-url] * [![JavaScript][JavaScript]][JavaScript-url] -

(back to top)

- ### Integrations | Folder | Purpose | |--------|---------| + | [`proxy/`](./proxy) | Shared auth gate and router. Forwards requests for known hosts to sibling Workers via service bindings; everything else falls through to a public `fetch()`. | | [`fivem/`](./fivem) | Resolves a Discord user to a player on a guild's FiveM server, with a KV-backed cache. | | [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. | -

(back to top)

- ## Deploying @@ -87,6 +80,7 @@ New integrations are picked up automatically — no workflow edits needed. | Secret | Purpose | |--------|---------| + | `CLOUDFLARE_API_TOKEN` | API token scoped to `Workers Scripts: Edit`, `Workers KV Storage: Edit`, `Workers Observability: Edit`, `Account Settings: Read`, `User Details: Read`. | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account that owns the Workers. | @@ -94,6 +88,7 @@ New integrations are picked up automatically — no workflow edits needed. | Worker | Secret | Purpose | |--------|--------|---------| + | `proxy` | `PROXY_AUTH_HEADER` | Header name callers send the auth token in. | | `proxy` | `PROXY_AUTH_KEY` | Shared token expected in that header. | | `fivem` | `FIVEM_AUTH_KEY` | Static guard token; callers must send this in the `Authorization` header. | @@ -101,18 +96,16 @@ New integrations are picked up automatically — no workflow edits needed. `SENTRY_DSN` for each Worker is configured in its `wrangler.toml` under `[vars]`. -

(back to top)

- ## Adding a new integration +Before writing any code, read [`INTEGRATION_STANDARDS.md`](./INTEGRATION_STANDARDS.md) — it defines what every Worker in this repo must implement. + 1. Create a new folder at the repository root (e.g. `myservice/`). 2. Add `index.js`, `wrangler.toml`, and `package.json`. 3. Commit and push to `main` — the deploy workflow auto-discovers the new folder. 4. If the Worker should be reachable via the `proxy`, add an entry to `SERVICE_BINDINGS` in `proxy/index.js` and a matching `[[services]]` block in `proxy/wrangler.toml`, then redeploy the proxy (service bindings require the target Worker to already exist). -

(back to top)

- ## Contributing @@ -127,8 +120,6 @@ Don't forget to give the project a star! Thanks again! 4. Push to the Branch (`git push origin feature/AmazingFeature`) 5. Open a Pull Request -

(back to top)

- ### Top contributors @@ -140,15 +131,11 @@ Don't forget to give the project a star! Thanks again! Distributed under the MIT license. See `LICENSE` for more information. -

(back to top)

- ## Acknowledgments * [TicketsBot.net](https://ticketsbot.net) For creating the original Tickets Bot -

(back to top)

- [contributors-shield]: https://img.shields.io/github/contributors/TicketsBot-cloud/integrations.svg?style=for-the-badge [contributors-url]: https://github.com/TicketsBot-cloud/integrations/graphs/contributors diff --git a/fivem/index.js b/fivem/index.js index e3cdb22..6f04056 100644 --- a/fivem/index.js +++ b/fivem/index.js @@ -118,7 +118,7 @@ async function handleLookup(request, env) { p.identifiers.includes(`discord:${userId}`), ); if (player === undefined) { - return jsonResponse({}, { status: 404 }); + return jsonResponse({}); } const payload = JSON.stringify(withProfileUrl(extractFields(player)), bigIntEncoder); From 441b7dd127bdd31ac12694e284c5c94227a7e6cf Mon Sep 17 00:00:00 2001 From: biast12 Date: Sat, 25 Apr 2026 21:49:33 +0200 Subject: [PATCH 2/4] Add back "back to top" --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 38949ec..e134ecb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + + [![Contributors][contributors-shield]][contributors-url] [![Forks][forks-shield]][forks-url] [![Stargazers][stars-shield]][stars-url] @@ -52,11 +54,15 @@ This repository contains the Cloudflare Workers that power Tickets' public integ The `proxy` Worker sits in front of the others: callers authenticate against the proxy once, and the proxy forwards matching requests to sibling Workers via service bindings so traffic stays on Cloudflare's network rather than egressing via the public internet. +

(back to top)

+ ### Built With * [![Cloudflare Workers][Workers]][Workers-url] * [![JavaScript][JavaScript]][JavaScript-url] +

(back to top)

+ ### Integrations | Folder | Purpose | @@ -66,6 +72,8 @@ The `proxy` Worker sits in front of the others: callers authenticate against the | [`fivem/`](./fivem) | Resolves a Discord user to a player on a guild's FiveM server, with a KV-backed cache. | | [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. | +

(back to top)

+ ## Deploying @@ -96,6 +104,8 @@ New integrations are picked up automatically — no workflow edits needed. `SENTRY_DSN` for each Worker is configured in its `wrangler.toml` under `[vars]`. +

(back to top)

+ ## Adding a new integration @@ -106,6 +116,8 @@ Before writing any code, read [`INTEGRATION_STANDARDS.md`](./INTEGRATION_STANDAR 3. Commit and push to `main` — the deploy workflow auto-discovers the new folder. 4. If the Worker should be reachable via the `proxy`, add an entry to `SERVICE_BINDINGS` in `proxy/index.js` and a matching `[[services]]` block in `proxy/wrangler.toml`, then redeploy the proxy (service bindings require the target Worker to already exist). +

(back to top)

+ ## Contributing @@ -120,6 +132,8 @@ Don't forget to give the project a star! Thanks again! 4. Push to the Branch (`git push origin feature/AmazingFeature`) 5. Open a Pull Request +

(back to top)

+ ### Top contributors @@ -131,11 +145,15 @@ Don't forget to give the project a star! Thanks again! Distributed under the MIT license. See `LICENSE` for more information. +

(back to top)

+ ## Acknowledgments * [TicketsBot.net](https://ticketsbot.net) For creating the original Tickets Bot +

(back to top)

+ [contributors-shield]: https://img.shields.io/github/contributors/TicketsBot-cloud/integrations.svg?style=for-the-badge [contributors-url]: https://github.com/TicketsBot-cloud/integrations/graphs/contributors From fd91e73d47251372e7876a24a1c71d119825900e Mon Sep 17 00:00:00 2001 From: biast12 Date: Sat, 25 Apr 2026 21:57:34 +0200 Subject: [PATCH 3/4] Update INTEGRATION_CACHE KV namespace ID Replace the shared KV namespace id for INTEGRATION_CACHE in INTEGRATION_STANDARDS.md (from 7901ae2b471145d4ab7b8535c158d892 to fbdf23642f6a40d0b5876abf3265910d). Keeps the documented wrangler.toml binding in sync with the current KV namespace. --- INTEGRATION_STANDARDS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/INTEGRATION_STANDARDS.md b/INTEGRATION_STANDARDS.md index db00d00..a3ae1ad 100644 --- a/INTEGRATION_STANDARDS.md +++ b/INTEGRATION_STANDARDS.md @@ -175,12 +175,12 @@ return handleLookup(request, env); Caching is **not required**, but **must** be used whenever upstream data is reasonably stable across requests. -Use the shared KV namespace binding `INTEGRATION_CACHE` (id `7901ae2b471145d4ab7b8535c158d892`). Declare it in `wrangler.toml`: +Use the shared KV namespace binding `INTEGRATION_CACHE` (id `fbdf23642f6a40d0b5876abf3265910d`). Declare it in `wrangler.toml`: ```toml [[kv_namespaces]] binding = "INTEGRATION_CACHE" -id = "7901ae2b471145d4ab7b8535c158d892" +id = "fbdf23642f6a40d0b5876abf3265910d" ``` **Cache key format:** `::` From 80cab9410387bec00d30904118aab56fcb4958dd Mon Sep 17 00:00:00 2001 From: biast12 Date: Sat, 25 Apr 2026 22:07:13 +0200 Subject: [PATCH 4/4] Remove stray blank lines in markdown tables Clean up markdown formatting by removing extraneous blank lines inside tables in INTEGRATION_STANDARDS.md and README.md. This preserves table rendering and keeps documentation consistent; no substantive content changes were made. --- INTEGRATION_STANDARDS.md | 3 --- README.md | 3 --- 2 files changed, 6 deletions(-) diff --git a/INTEGRATION_STANDARDS.md b/INTEGRATION_STANDARDS.md index a3ae1ad..f3afddb 100644 --- a/INTEGRATION_STANDARDS.md +++ b/INTEGRATION_STANDARDS.md @@ -67,7 +67,6 @@ Every worker **must** wrap its `fetch` handler with `Sentry.withSentry` from `@s | Field | Value | |-------|-------| - | `dsn` | `env.SENTRY_DSN` | | `tracesSampleRate` | `1.0` | | `sendDefaultPii` | `true` | @@ -194,7 +193,6 @@ Examples: | Data type | `expirationTtl` | |-----------|-----------------| - | Slow-changing (profile data, account info) | `86400` (24 h) | | Live / session data (online players) | `300` (5 min) | @@ -231,7 +229,6 @@ return new Response(payload, { | Condition | Status | Body | |-----------|--------|------| - | Success with data | `200` | `{ ...fields }` | | User not found / not linked | `200` | `{}` | | Bad request (missing field, invalid format) | `400` | `{ "error": "..." }` | diff --git a/README.md b/README.md index e134ecb..4f19482 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,6 @@ The `proxy` Worker sits in front of the others: callers authenticate against the | Folder | Purpose | |--------|---------| - | [`proxy/`](./proxy) | Shared auth gate and router. Forwards requests for known hosts to sibling Workers via service bindings; everything else falls through to a public `fetch()`. | | [`fivem/`](./fivem) | Resolves a Discord user to a player on a guild's FiveM server, with a KV-backed cache. | | [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. | @@ -88,7 +87,6 @@ New integrations are picked up automatically — no workflow edits needed. | Secret | Purpose | |--------|---------| - | `CLOUDFLARE_API_TOKEN` | API token scoped to `Workers Scripts: Edit`, `Workers KV Storage: Edit`, `Workers Observability: Edit`, `Account Settings: Read`, `User Details: Read`. | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account that owns the Workers. | @@ -96,7 +94,6 @@ New integrations are picked up automatically — no workflow edits needed. | Worker | Secret | Purpose | |--------|--------|---------| - | `proxy` | `PROXY_AUTH_HEADER` | Header name callers send the auth token in. | | `proxy` | `PROXY_AUTH_KEY` | Shared token expected in that header. | | `fivem` | `FIVEM_AUTH_KEY` | Static guard token; callers must send this in the `Authorization` header. |