From a0755ff1e6a92557f2e8133dac45d41c43c4bdb8 Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:04:35 -0400 Subject: [PATCH 1/9] Added base files needed for google docs intigration. --- README.md | 2 ++ googledocs/index.js | 51 ++++++++++++++++++++++++++++++++++++++++ googledocs/package.json | 15 ++++++++++++ googledocs/wrangler.toml | 15 ++++++++++++ proxy/index.js | 1 + proxy/wrangler.toml | 4 ++++ 6 files changed, 88 insertions(+) create mode 100644 googledocs/index.js create mode 100644 googledocs/package.json create mode 100644 googledocs/wrangler.toml diff --git a/README.md b/README.md index b91f2e6..b67f698 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ The `proxy` Worker sits in front of the others: callers authenticate against the | [`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) | Looks up a FiveM server by ID and resolves a Discord snowflake to a player on that server. | | [`bloxlink/`](./bloxlink) | Resolves a Discord user to their linked Roblox account via Bloxlink, with a KV-backed cache. | +| [`googledocs/`](./googledocs) | Scaffold for writing ticket data to a Google Sheet (placeholder only; API calls not implemented yet). |

(back to top)

@@ -98,6 +99,7 @@ New integrations are picked up automatically — no workflow edits needed. | `proxy` | `PROXY_AUTH_KEY` | Shared token expected in that header. | | `fivem` | `FIVEM_AUTH_KEY` | Token expected in the `Authorization` header. | | `bloxlink` | `BLOXLINK_AUTH_KEY` | Token expected in the `X-Tickets-Auth` header. | +| `googledocs` | `GOOGLEDOCS_AUTH_KEY` | Token expected in the `X-Tickets-Auth` header. | `SENTRY_DSN` for each Worker is configured in its `wrangler.toml` under `[vars]`. diff --git a/googledocs/index.js b/googledocs/index.js new file mode 100644 index 0000000..f864e3f --- /dev/null +++ b/googledocs/index.js @@ -0,0 +1,51 @@ +import * as Sentry from "@sentry/cloudflare"; + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function handleRequest(request, env) { + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method Not Allowed" }), { + status: 405, + headers: { + "content-type": "application/json", + allow: "POST", + }, + }); + } + + if (request.headers.get("X-Tickets-Auth") !== env.GOOGLEDOCS_AUTH_KEY) { + return jsonResponse(401, { error: "Invalid auth key" }); + } + + let body; + try { + body = await request.json(); + } catch { + return jsonResponse(400, { error: "Invalid request body" }); + } + + // Placeholder response so the Worker can be deployed and wired before + // Google Sheets writing is implemented. + return jsonResponse(501, { + error: "Google Docs integration is not implemented yet", + received: body, + }); +} + +export default Sentry.withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(request, env) { + return handleRequest(request, env); + }, + }, +); diff --git a/googledocs/package.json b/googledocs/package.json new file mode 100644 index 0000000..da4626c --- /dev/null +++ b/googledocs/package.json @@ -0,0 +1,15 @@ +{ + "name": "googledocs-integration", + "private": true, + "main": "index.js", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev" + }, + "dependencies": { + "@sentry/cloudflare": "^10.49.0" + }, + "devDependencies": { + "wrangler": "^4.83.0" + } +} diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml new file mode 100644 index 0000000..db55787 --- /dev/null +++ b/googledocs/wrangler.toml @@ -0,0 +1,15 @@ +name = "googledocs" +main = "index.js" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat"] + +[observability.logs] +enabled = true + +[vars] +SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13" + +# Secrets (set via `wrangler secret put `): +# GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header +# GOOGLEDOCS_SPREADSHEET_ID — target Google Sheets spreadsheet ID +# GOOGLEDOCS_SERVICE_ACCOUNT_JSON — service account JSON for Google APIs diff --git a/proxy/index.js b/proxy/index.js index d8ca0d9..60a094d 100644 --- a/proxy/index.js +++ b/proxy/index.js @@ -16,6 +16,7 @@ const SERVICE_BINDINGS = { "fivem.tickets-v2.workers.dev": "FIVEM", "guild-lookup-worker.tickets-v2.workers.dev": "GUILDLOOKUP", "bloxlink.tickets-v2.workers.dev": "BLOXLINK", + "googledocs.tickets-v2.workers.dev": "GOOGLEDOCS", }; function errorResponse(status, message) { diff --git a/proxy/wrangler.toml b/proxy/wrangler.toml index a34afd4..bd9e5b7 100644 --- a/proxy/wrangler.toml +++ b/proxy/wrangler.toml @@ -23,6 +23,10 @@ service = "guild-lookup-worker" binding = "BLOXLINK" service = "bloxlink" +[[services]] +binding = "GOOGLEDOCS" +service = "googledocs" + # Secrets (set via `wrangler secret put `): # PROXY_AUTH_HEADER — name of the header callers send the auth token in # PROXY_AUTH_KEY — shared token expected in that header From 9da3f834e5761c697d1fdfc817211f02c6d7df99 Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:33:18 -0400 Subject: [PATCH 2/9] First upload of real code. This is a baseline program to handle tickets bot interaction. Co-authored-by: Copilot --- .gitignore | 1 + googledocs/index.js | 1257 ++++++++++++++++++++++++++- googledocs/package-lock.json | 1546 ++++++++++++++++++++++++++++++++++ googledocs/wrangler.toml | 7 +- 4 files changed, 2795 insertions(+), 16 deletions(-) create mode 100644 googledocs/package-lock.json diff --git a/.gitignore b/.gitignore index 0dcc8a4..60b6c82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ .wrangler/ +RankBloxTicket+/ diff --git a/googledocs/index.js b/googledocs/index.js index f864e3f..9d37cd7 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -1,5 +1,40 @@ import * as Sentry from "@sentry/cloudflare"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets"; +const DEFAULT_SHEET_TAB = "Tickets"; +const DEFAULT_SPREADSHEET_ID_HEADER = "X-Tickets-Google-Sheets-Id"; +const DEFAULT_SHEET_TAB_HEADER = "X-Tickets-Google-Sheet-Tab"; +const GOOGLE_WRITE_MAX_ATTEMPTS = 2; +const GOOGLE_WRITE_DEBOUNCE_MS = 750; +const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500; +const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url"; +const BOT_API_SECRET_HEADER = "X-Tickets-Bot-Api-Secret"; +const TICKET_COLUMNS = 11; +const TICKET_HEADERS = [ + "Ticket ID", + "Channel ID", + "Status", + "Guild ID", + "User ID", + "Claimed By", + "Close Requested By", + "Opened At", + "First Reply At", + "Closed At", + "Panel Title", +]; + +let cachedAccessToken = null; +let cachedAccessTokenExpiry = 0; +let sheetsInitialized = false; +let initializedSheetKey = null; +const sheetRowByChannelId = new Map(); +const sheetRowByTicketId = new Map(); +const googleSheetsWriteQueueByKey = new Map(); +let googleSheetsGlobalWriteChain = Promise.resolve(); +let googleSheetsLastWriteAt = 0; + function jsonResponse(status, body) { return new Response(JSON.stringify(body), { status, @@ -7,33 +42,1225 @@ function jsonResponse(status, body) { }); } -async function handleRequest(request, env) { - if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method Not Allowed" }), { - status: 405, +function errorResponse(status, message) { + return jsonResponse(status, { error: message }); +} + +function getHeaderValue(request, name) { + const value = request.headers.get(name); + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeConfiguredTicketValue(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getSpreadsheetConfig(request, env) { + const spreadsheetId = + getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER) || + readRequiredEnv(env, "GOOGLEDOCS_SPREADSHEET_ID"); + const sheetTab = + getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) || + env.GOOGLEDOCS_SHEET_TAB || + DEFAULT_SHEET_TAB; + + return { spreadsheetId, sheetTab }; +} + +function getBotApiConfig(request, env) { + const botApiUrl = getHeaderValue(request, BOT_API_URL_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL"); + const botApiSecret = getHeaderValue(request, BOT_API_SECRET_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET"); + + return { + botApiUrl, + botApiSecret, + }; +} + +function isSameOriginUrl(leftUrl, rightUrl) { + try { + return new URL(leftUrl).origin === new URL(rightUrl).origin; + } catch { + return false; + } +} + +function getExpectedAuthKey(env) { + return ( + readRequiredEnv(env, "GOOGLEDOCS_AUTH_KEY") || + readRequiredEnv(env, "TICKETS_SHARED_SECRET") || + readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET") + ); +} + +function isValidAuthRequest(request, env) { + const expected = getExpectedAuthKey(env); + if (!expected) { + return true; + } + + const provided = + getHeaderValue(request, "X-Tickets-Auth") || + getHeaderValue(request, "X-Tickets-Secret") || + getHeaderValue(request, "Authorization") || + ""; + const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided; + return normalized === expected; +} + +async function validateSecrets(env, secrets) { + const spreadsheetId = secrets.spreadsheet_id || secrets.GOOGLEDOCS_SPREADSHEET_ID; + const sheetTab = secrets.sheet_tab || secrets.GOOGLEDOCS_SHEET_TAB || DEFAULT_SHEET_TAB; + + if (!spreadsheetId) { + return errorResponse(400, "Missing spreadsheet_id."); + } + + if (!isSpreadsheetId(spreadsheetId)) { + return errorResponse(400, "Your spreadsheet_id is invalid."); + } + + if (!isValidSheetTabName(sheetTab)) { + return errorResponse(400, "Your sheet_tab is invalid."); + } + + return verifySpreadsheetAccess(env, spreadsheetId, sheetTab); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function base64UrlEncodeText(text) { + const utf8 = new TextEncoder().encode(text); + return base64UrlEncodeBytes(utf8); +} + +function base64UrlEncodeBytes(bytes) { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function pemPrivateKeyToArrayBuffer(pem) { + const stripped = pem + .replace(/\r/g, "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace(/\s+/g, ""); + + const binary = atob(stripped); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +async function signServiceAccountJwt(serviceEmail, privateKey) { + const header = { alg: "RS256", typ: "JWT" }; + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: serviceEmail, + scope: GOOGLE_SHEETS_SCOPE, + aud: GOOGLE_TOKEN_URL, + exp: now + 3600, + iat: now, + }; + + const encodedHeader = base64UrlEncodeText(JSON.stringify(header)); + const encodedPayload = base64UrlEncodeText(JSON.stringify(payload)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + + const key = await crypto.subtle.importKey( + "pkcs8", + pemPrivateKeyToArrayBuffer(privateKey), + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + new TextEncoder().encode(signingInput), + ); + + const encodedSignature = base64UrlEncodeBytes(new Uint8Array(signature)); + return `${signingInput}.${encodedSignature}`; +} + +function readRequiredEnv(env, key) { + const value = env[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function isSpreadsheetId(value) { + return typeof value === "string" && /^[a-zA-Z0-9-_]{20,200}$/.test(value); +} + +function isValidSheetTabName(value) { + return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 100; +} + +function buildGoogleSheetsUrl(spreadsheetId, range) { + return `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`; +} + +function buildHeadersUrl(spreadsheetId, sheetTab) { + return `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A1:K1?valueInputOption=USER_ENTERED`; +} + +function buildSheetRange(sheetTab, rowNumber) { + return `${sheetTab}!A${rowNumber}:K${rowNumber}`; +} + +function getTicketWriteKey(spreadsheetId, sheetTab, payload) { + const event = normaliseEventPayload(payload); + const stableId = event.ticketId || event.channelId; + if (!spreadsheetId || !sheetTab || !stableId) { + return null; + } + + return `${spreadsheetId}::${sheetTab}::${stableId}`; +} + +function getTicketWriteSignature(payload) { + return JSON.stringify(buildTicketRowFromEvent(payload)); +} + +function getTicketWriteQueueState(key) { + let state = googleSheetsWriteQueueByKey.get(key); + if (!state) { + state = { + timerId: null, + running: false, + pendingPayload: null, + promise: null, + resolve: null, + reject: null, + lastSignature: null, + lastWriteAt: 0, + }; + googleSheetsWriteQueueByKey.set(key, state); + } + + return state; +} + +function resetTicketWriteQueueState(key) { + const state = googleSheetsWriteQueueByKey.get(key); + if (!state) { + return; + } + + if (state.timerId) { + clearTimeout(state.timerId); + } + + googleSheetsWriteQueueByKey.delete(key); +} + +async function runGoogleSheetsWrite(task) { + const next = googleSheetsGlobalWriteChain.then(async () => { + const elapsed = Date.now() - googleSheetsLastWriteAt; + if (googleSheetsLastWriteAt > 0 && elapsed < GOOGLE_WRITE_MIN_INTERVAL_MS) { + await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed); + } + + const result = await task(); + googleSheetsLastWriteAt = Date.now(); + return result; + }); + + googleSheetsGlobalWriteChain = next.catch(() => null); + return next; +} + +async function flushTicketWriteQueue(key, env, spreadsheetId, sheetTab) { + const state = googleSheetsWriteQueueByKey.get(key); + if (!state || state.running) { + return null; + } + + state.running = true; + let lastResult = { mode: "skipped", reason: "No pending ticket write" }; + + try { + while (state.pendingPayload) { + const payload = state.pendingPayload; + state.pendingPayload = null; + + const signature = getTicketWriteSignature(payload); + if (signature === state.lastSignature) { + lastResult = { mode: "skipped", reason: "Duplicate ticket state" }; + continue; + } + + lastResult = await runGoogleSheetsWrite(() => upsertTicketToSheet(env, payload, spreadsheetId, sheetTab)); + state.lastSignature = signature; + state.lastWriteAt = Date.now(); + } + + if (state.resolve) { + state.resolve(lastResult); + } + + return lastResult; + } catch (error) { + if (state.reject) { + state.reject(error); + } + throw error; + } finally { + state.running = false; + state.timerId = null; + state.promise = null; + state.resolve = null; + state.reject = null; + state.pendingPayload = null; + + if (!state.lastSignature) { + resetTicketWriteQueueState(key); + } + } +} + +function queueTicketWrite(env, spreadsheetId, sheetTab, payload) { + const key = getTicketWriteKey(spreadsheetId, sheetTab, payload); + if (!key) { + return upsertTicketToSheet(env, payload, spreadsheetId, sheetTab); + } + + const state = getTicketWriteQueueState(key); + state.pendingPayload = payload; + + if (!state.promise) { + state.promise = new Promise((resolve, reject) => { + state.resolve = resolve; + state.reject = reject; + }); + } + + if (state.timerId === null && !state.running) { + const elapsed = state.lastWriteAt > 0 ? Date.now() - state.lastWriteAt : null; + const delayMs = elapsed === null ? GOOGLE_WRITE_DEBOUNCE_MS : Math.max(GOOGLE_WRITE_DEBOUNCE_MS, GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed); + + state.timerId = setTimeout(() => { + state.timerId = null; + flushTicketWriteQueue(key, env, spreadsheetId, sheetTab).catch((error) => { + console.error("[DEBUG] Google Sheets write queue failed:", error); + }); + }, delayMs); + } + + return state.promise; +} + +function getSheetCacheKey(spreadsheetId, sheetTab) { + return `${spreadsheetId}::${sheetTab}`; +} + +function resetSheetCache() { + sheetsInitialized = false; + initializedSheetKey = null; + sheetRowByChannelId.clear(); + sheetRowByTicketId.clear(); +} + +function parseRetryAfter(headerValue) { + if (!headerValue) { + return null; + } + + const seconds = Number(headerValue); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const dateMs = Date.parse(headerValue); + if (Number.isFinite(dateMs)) { + return Math.max(0, dateMs - Date.now()); + } + + return null; +} + +function classifySheetsFailure(response, bodyText) { + if (response.status === 401) { + return { + status: 401, + message: "Google authentication failed. Check the service account email and private key.", + }; + } + + if (response.status === 403) { + return { + status: 403, + message: + "Google Sheets permission denied. Share the spreadsheet with the service account email as Editor.", + }; + } + + if (response.status === 404) { + return { + status: 404, + message: + "Google Sheets spreadsheet was not found. Verify the spreadsheet ID and sharing permissions.", + }; + } + + if (response.status === 429) { + return { + status: 429, + message: "Google Sheets rate limit exceeded. The worker will retry automatically.", + }; + } + + if (response.status >= 500) { + return { + status: response.status, + message: `Google Sheets is temporarily unavailable (${response.status}).`, + }; + } + + return { + status: response.status, + message: `Google Sheets append failed (${response.status}): ${bodyText}`, + }; +} + +function getEventType(payload) { + return String(payload.event_type || payload.type || payload.event || "").trim().toLowerCase(); +} + +function normaliseEventPayload(payload) { + const openedAt = payload.opened_at || payload.created_at || new Date().toISOString(); + const eventType = getEventType(payload); + const status = + payload.status || + (eventType === "closed" + ? "closed" + : eventType === "close_requested" + ? "close_requested" + : eventType === "claimed" + ? "claimed" + : "open"); + + return { + ticketId: payload.ticket_id || payload.ticketId || "", + channelId: payload.ticket_channel_id || payload.channel_id || payload.channelId || "", + status, + guildId: payload.guild_id || payload.guildId || "", + userId: payload.user_id || payload.userId || "", + claimedBy: payload.claimed_by || payload.claimedBy || "", + closeRequestedBy: payload.close_requested_by || payload.closeRequestedBy || "", + openedAt, + firstReplyAt: payload.first_reply_at || payload.first_response_at || payload.firstReplyAt || "", + closedAt: payload.closed_at || payload.closedAt || "", + panelTitle: payload.panel_title || payload.panelTitle || "", + }; +} + +function buildTicketRowFromEvent(payload) { + const event = normaliseEventPayload(payload); + + return [ + toCellValue(event.ticketId), + toCellValue(event.channelId), + toCellValue(event.status), + toCellValue(event.guildId), + toCellValue(event.userId), + toCellValue(event.claimedBy), + toCellValue(event.closeRequestedBy), + toCellValue(event.openedAt), + toCellValue(event.firstReplyAt), + toCellValue(event.closedAt), + toCellValue(event.panelTitle), + ]; +} + +function buildBotSeedRecord(payload) { + const event = normaliseEventPayload(payload); + + return { + ticket_id: event.ticketId, + ticket_channel_id: event.channelId, + status: event.status, + guild_id: event.guildId, + user_id: event.userId, + claimed_by: event.claimedBy, + close_requested_by: event.closeRequestedBy, + opened_at: event.openedAt, + first_reply_at: event.firstReplyAt, + closed_at: event.closedAt, + panel_title: event.panelTitle, + }; +} + +async function notifyBotApi(request, env, records, options = {}) { + const { botApiUrl, botApiSecret } = getBotApiConfig(request, env); + if (!botApiUrl) { + console.log("[DEBUG] No bot API URL configured; skipping bot sync."); + return null; + } + + if (request?.url && isSameOriginUrl(request.url, botApiUrl)) { + console.warn( + `[DEBUG] RANKBLOX_TICKETS_API_URL points to this worker (${botApiUrl}); skipping bot sync to avoid self-posting.`, + ); + return null; + } + + const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env); + const payload = Array.isArray(records) ? records : [records]; + console.log(`[DEBUG] Syncing ${payload.length} ticket record(s) to bot API: ${botApiUrl}`); + const response = await fetch(`${botApiUrl.replace(/\/$/, "")}/api/tickets/seed`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(botApiSecret ? { "X-Tickets-Secret": botApiSecret } : {}), + ...(spreadsheetId ? { [DEFAULT_SPREADSHEET_ID_HEADER]: spreadsheetId } : {}), + ...(sheetTab ? { [DEFAULT_SHEET_TAB_HEADER]: sheetTab } : {}), + }, + body: JSON.stringify({ + tickets: payload, + replace: options.replace === true, + }), + }); + + if (!response.ok) { + const details = await response.text().catch(() => ""); + throw new Error(`Bot API sync failed (${response.status}): ${details || response.statusText}`); + } + + return response.json().catch(() => null); +} + +async function decideWithBotApi(request, env, payload) { + const { botApiUrl, botApiSecret } = getBotApiConfig(request, env); + if (!botApiUrl) { + return { + shouldWrite: true, + ticket: buildBotSeedRecord(payload), + }; + } + + if (request?.url && isSameOriginUrl(request.url, botApiUrl)) { + return { + shouldWrite: true, + ticket: buildBotSeedRecord(payload), + }; + } + + const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env); + const response = await fetch(`${botApiUrl.replace(/\/$/, "")}/api/tickets/decide`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(botApiSecret ? { "X-Tickets-Secret": botApiSecret } : {}), + ...(spreadsheetId ? { [DEFAULT_SPREADSHEET_ID_HEADER]: spreadsheetId } : {}), + ...(sheetTab ? { [DEFAULT_SHEET_TAB_HEADER]: sheetTab } : {}), + }, + body: JSON.stringify(buildBotSeedRecord(payload)), + }); + + if (!response.ok) { + const details = await response.text().catch(() => ""); + throw new Error(`Bot decision request failed (${response.status}): ${details || response.statusText}`); + } + + const decision = await response.json().catch(() => null); + return { + shouldWrite: decision?.should_write !== false, + ticket: decision?.ticket || buildBotSeedRecord(payload), + }; +} + +function normalizeTicketSeedEntry(entry, fallback = {}) { + if (typeof entry === "string") { + return { + ticket_channel_id: entry, + guild_id: fallback.guild_id || fallback.guildId || "", + user_id: fallback.user_id || fallback.userId || "", + ticket_id: fallback.ticket_id || fallback.ticketId || "", + status: fallback.status || "open", + panel_title: fallback.panel_title || fallback.panelTitle || "", + opened_at: fallback.opened_at || fallback.openedAt || new Date().toISOString(), + claimed_by: fallback.claimed_by || fallback.claimedBy || "", + close_requested_by: fallback.close_requested_by || fallback.closeRequestedBy || "", + first_reply_at: fallback.first_reply_at || fallback.firstReplyAt || "", + closed_at: fallback.closed_at || fallback.closedAt || "", + }; + } + + if (!entry || typeof entry !== "object") { + return null; + } + + const ticketChannelId = entry.ticket_channel_id || entry.channel_id || entry.channelId; + if (!ticketChannelId) { + return null; + } + + return { + ticket_id: entry.ticket_id || entry.ticketId || fallback.ticket_id || fallback.ticketId || "", + ticket_channel_id: ticketChannelId, + status: entry.status || fallback.status || "open", + guild_id: entry.guild_id || entry.guildId || fallback.guild_id || fallback.guildId || "", + user_id: entry.user_id || entry.userId || fallback.user_id || fallback.userId || "", + claimed_by: entry.claimed_by || entry.claimedBy || fallback.claimed_by || fallback.claimedBy || "", + close_requested_by: + entry.close_requested_by || + entry.closeRequestedBy || + fallback.close_requested_by || + fallback.closeRequestedBy || + "", + opened_at: + entry.opened_at || entry.openedAt || fallback.opened_at || fallback.openedAt || new Date().toISOString(), + first_reply_at: + entry.first_reply_at || entry.first_response_at || entry.firstReplyAt || fallback.firstReplyAt || "", + closed_at: entry.closed_at || entry.closedAt || fallback.closed_at || fallback.closedAt || "", + panel_title: entry.panel_title || entry.panelTitle || fallback.panel_title || fallback.panelTitle || "", + }; +} + +async function writeTicketSeedBatch(request, env, spreadsheetId, sheetTab, records) { + if (!Array.isArray(records) || records.length === 0) { + return []; + } + + await ensureSheetsInitialized(env, spreadsheetId, sheetTab); + + const results = []; + for (const entry of records) { + const normalized = normalizeTicketSeedEntry(entry); + if (!normalized) { + continue; + } + + const result = await queueTicketWrite(env, spreadsheetId, sheetTab, normalized); + results.push({ + channel_id: normalized.ticket_channel_id, + mode: result.mode, + row_number: result.rowNumber, + }); + } + + if (results.length > 0) { + notifyBotApi(request, env, records.map((entry) => normalizeTicketSeedEntry(entry)).filter(Boolean), { + replace: false, + }).catch((error) => { + console.error("[DEBUG] Background bot notify failed:", error.message); + }); + } + + return results; +} + +async function getGoogleAccessToken(env) { + const now = Math.floor(Date.now() / 1000); + if (cachedAccessToken && cachedAccessTokenExpiry - 60 > now) { + return cachedAccessToken; + } + + const serviceEmail = readRequiredEnv(env, "GOOGLE_SERVICE_ACCOUNT_EMAIL"); + const privateKeyRaw = readRequiredEnv(env, "GOOGLE_PRIVATE_KEY"); + if (!serviceEmail || !privateKeyRaw) { + throw new Error("Missing Google service account credentials"); + } + + const privateKey = privateKeyRaw.replace(/\\n/g, "\n"); + const assertion = await signServiceAccountJwt(serviceEmail, privateKey); + + const tokenResponse = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + }), + }); + + if (!tokenResponse.ok) { + const details = await tokenResponse.text(); + throw new Error(`Token exchange failed (${tokenResponse.status}): ${details}`); + } + + const tokenData = await tokenResponse.json(); + cachedAccessToken = tokenData.access_token; + cachedAccessTokenExpiry = now + Number(tokenData.expires_in || 3600); + return cachedAccessToken; +} + +async function ensureSheetsInitialized(env, spreadsheetId, sheetTab) { + const sheetKey = getSheetCacheKey(spreadsheetId, sheetTab); + if (sheetsInitialized && initializedSheetKey === sheetKey) { + return; + } + + if (sheetsInitialized && initializedSheetKey !== sheetKey) { + resetSheetCache(); + } + + await verifySpreadsheetAccess(env, spreadsheetId, sheetTab); + await ensureTicketHeaders(env, spreadsheetId, sheetTab); + await loadSheetRowIndexCache(env, spreadsheetId, sheetTab); + initializedSheetKey = sheetKey; + sheetsInitialized = true; +} + +async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) { + const accessToken = await getGoogleAccessToken(env); + const response = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + return; + } + + sheetRowByChannelId.clear(); + sheetRowByTicketId.clear(); + const data = await response.json(); + const rows = Array.isArray(data.values) ? data.values : []; + + rows.forEach((row, index) => { + const ticketId = row?.[0]; + const channelId = row?.[1]; + const rowNumber = index + 2; + + if (ticketId) { + sheetRowByTicketId.set(String(ticketId), rowNumber); + } + + if (channelId) { + sheetRowByChannelId.set(String(channelId), rowNumber); + } + }); +} + +async function resolveExistingTicketRowNumber(env, spreadsheetId, sheetTab, ticketId, channelId) { + if (ticketId) { + const cachedTicketRowNumber = sheetRowByTicketId.get(String(ticketId)); + if (cachedTicketRowNumber) { + return cachedTicketRowNumber; + } + } + + if (channelId) { + const cachedChannelRowNumber = sheetRowByChannelId.get(String(channelId)); + if (cachedChannelRowNumber) { + return cachedChannelRowNumber; + } + } + + const accessToken = await getGoogleAccessToken(env); + const response = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + return null; + } + + const data = await response.json().catch(() => null); + const rows = Array.isArray(data?.values) ? data.values : []; + + rows.forEach((row, index) => { + const rowTicketId = row?.[0]; + const rowChannelId = row?.[1]; + const rowNumber = index + 2; + + if (rowTicketId) { + sheetRowByTicketId.set(String(rowTicketId), rowNumber); + } + + if (rowChannelId) { + sheetRowByChannelId.set(String(rowChannelId), rowNumber); + } + }); + + if (ticketId && sheetRowByTicketId.has(String(ticketId))) { + return sheetRowByTicketId.get(String(ticketId)) || null; + } + + return channelId ? sheetRowByChannelId.get(String(channelId)) || null : null; +} + +async function verifySpreadsheetAccess(env, spreadsheetId, sheetTab) { + const accessToken = await getGoogleAccessToken(env); + const response = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`, + { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + if (response.status === 403) { + return errorResponse( + 400, + "Google Sheets permission denied. Share the spreadsheet with the service account email as Editor.", + ); + } + + if (response.status === 404) { + return errorResponse(400, "Google Sheets spreadsheet was not found."); + } + + if (response.status === 429) { + return errorResponse(400, "Google Sheets rate limit exceeded. Try again shortly."); + } + + return errorResponse(400, "Unable to verify spreadsheet access."); + } + + const data = await response.json(); + const sheets = Array.isArray(data.sheets) ? data.sheets : []; + const tabExists = sheets.some((sheet) => sheet?.properties?.title === sheetTab); + + if (!tabExists) { + return errorResponse(400, "Your sheet_tab was not found in the spreadsheet."); + } + + return null; +} + +function toCellValue(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value); +} + +function buildTicketRow(payload) { + const openedAt = payload.opened_at || payload.created_at || new Date().toISOString(); + const firstReplyAt = payload.first_reply_at || payload.first_response_at || ""; + const closedAt = payload.closed_at || ""; + const status = payload.status || (payload.is_closed ? "closed" : "open"); + + return [ + toCellValue(payload.ticket_id), + toCellValue(payload.ticket_channel_id || payload.channel_id), + toCellValue(status), + toCellValue(payload.guild_id), + toCellValue(payload.user_id), + toCellValue(payload.claimed_by), + toCellValue(payload.close_requested_by), + toCellValue(openedAt), + toCellValue(firstReplyAt), + toCellValue(closedAt), + toCellValue(payload.panel_title), + ]; +} + +async function ensureTicketHeaders(env, spreadsheetId, sheetTab) { + const accessToken = await getGoogleAccessToken(env); + const response = await fetch(buildHeadersUrl(spreadsheetId, sheetTab), { + method: "PUT", + headers: { + "content-type": "application/json", + authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ values: [TICKET_HEADERS] }), + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Failed to write sheet headers (${response.status}): ${details}`); + } +} + +async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) { + if (!spreadsheetId) { + throw new Error("Missing GOOGLEDOCS_SPREADSHEET_ID"); + } + + if (!isSpreadsheetId(spreadsheetId)) { + throw new Error("Invalid spreadsheet ID"); + } + + await ensureSheetsInitialized(env, spreadsheetId, sheetTab); + + const rowValues = buildTicketRowFromEvent(payload); + const ticketId = rowValues[0]; + const channelId = rowValues[1]; + const existingRowNumber = await resolveExistingTicketRowNumber(env, spreadsheetId, sheetTab, ticketId, channelId); + const accessToken = await getGoogleAccessToken(env); + + const requestBody = JSON.stringify({ + majorDimension: "ROWS", + values: [rowValues], + }); + + if (existingRowNumber) { + const response = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(buildSheetRange(sheetTab, existingRowNumber))}?valueInputOption=USER_ENTERED`, + { + method: "PUT", + headers: { + "content-type": "application/json", + authorization: `Bearer ${accessToken}`, + }, + body: requestBody, + }, + ); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Failed to update ticket row (${response.status}): ${details}`); + } + + return { mode: "updated", rowNumber: existingRowNumber }; + } + + let lastError = null; + for (let attempt = 1; attempt <= GOOGLE_WRITE_MAX_ATTEMPTS; attempt += 1) { + const response = await fetch(buildGoogleSheetsUrl(spreadsheetId, `${sheetTab}!A:K`), { + method: "POST", headers: { "content-type": "application/json", - allow: "POST", + authorization: `Bearer ${accessToken}`, }, + body: requestBody, + }); + + if (response.ok) { + const data = await response.json().catch(() => null); + const updatedRange = data?.updates?.updatedRange || ""; + const match = updatedRange.match(/!(?:[A-Z]+)(\d+):/); + if (match) { + const rowNumber = Number(match[1]); + if (ticketId) { + sheetRowByTicketId.set(String(ticketId), rowNumber); + } + if (channelId) { + sheetRowByChannelId.set(String(channelId), rowNumber); + } + } + return { mode: "inserted", rowNumber: (ticketId && sheetRowByTicketId.get(String(ticketId))) || (channelId && sheetRowByChannelId.get(String(channelId))) || null }; + } + + const details = await response.text(); + const classified = classifySheetsFailure(response, details); + lastError = new Error(classified.message); + + if (response.status !== 429 && response.status < 500) { + throw lastError; + } + + if (attempt < GOOGLE_WRITE_MAX_ATTEMPTS) { + const retryAfterMs = parseRetryAfter(response.headers.get("retry-after")); + const backoffMs = retryAfterMs ?? Math.min(5000, 500 * 2 ** (attempt - 1)); + await sleep(backoffMs); + } + } + + throw lastError || new Error("Failed to write ticket to Google Sheets"); +} + +async function processLifecycleEvent(payload, env, spreadsheetId, sheetTab) { + if (getEventType(payload) === "first_response" && !payload.first_reply_at && !payload.first_response_at) { + payload.first_reply_at = new Date().toISOString(); + } + + if (getEventType(payload) === "closed" && !payload.closed_at) { + payload.closed_at = new Date().toISOString(); + } + + return queueTicketWrite(env, spreadsheetId, sheetTab, payload); +} + +function queueBotSync(request, env, payload, note) { + notifyBotApi(request, env, buildBotSeedRecord(payload)) + .then(() => { + console.log(`[DEBUG] Bot sync queued successfully${note ? ` (${note})` : ""}`); + }) + .catch((error) => { + console.error(`[DEBUG] Bot sync failed${note ? ` (${note})` : ""}:`, error); + }); +} + +async function handleLifecycleEvent(request, env, ctx) { + const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env); + if (!spreadsheetId) { + return errorResponse(400, "Missing spreadsheet ID."); + } + + let payload; + try { + payload = await request.json(); + } catch { + return errorResponse(400, "Invalid request body"); + } + + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return errorResponse(400, "Request body must be a JSON object"); + } + + const channelId = payload.ticket_channel_id || payload.channel_id || payload.channelId; + if (!channelId) { + return errorResponse(400, "Missing ticket_channel_id"); + } + + const writeTask = processLifecycleEvent(payload, env, spreadsheetId, sheetTab) + .then((result) => { + console.log(`[DEBUG] Lifecycle event written for channel ${channelId}: ${result.mode}`); + }) + .catch((error) => { + console.error("Lifecycle event write failed", error); + }); + + queueBotSync(request, env, payload, `channel ${channelId}`); + + if (ctx?.waitUntil) { + ctx.waitUntil(writeTask); + } + + return jsonResponse(202, { + ok: true, + queued: true, + event_type: getEventType(payload), + spreadsheet_id: spreadsheetId, + sheet_tab: sheetTab, + }); +} + +async function handleTicketSeedRequest(request, env) { + const { spreadsheetId, sheetTab } = getSpreadsheetConfig(request, env); + if (!spreadsheetId) { + return errorResponse(400, "Missing spreadsheet ID."); + } + + let payload; + try { + payload = await request.json(); + } catch { + return errorResponse(400, "Invalid request body"); + } + + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return errorResponse(400, "Request body must be a JSON object"); + } + + const records = Array.isArray(payload.tickets) + ? payload.tickets + : Array.isArray(payload.ticket_channels) + ? payload.ticket_channels + : Array.isArray(payload.channel_ids) + ? payload.channel_ids + : Array.isArray(payload.channelIds) + ? payload.channelIds + : []; + + if (records.length === 0) { + return errorResponse(400, "Missing tickets, ticket_channels, or channel_ids"); + } + + try { + const normalizedFallback = { + ticket_id: payload.ticket_id || payload.ticketId || "", + guild_id: payload.guild_id || payload.guildId || "", + user_id: payload.user_id || payload.userId || "", + status: payload.status || "open", + panel_title: payload.panel_title || payload.panelTitle || "", + opened_at: payload.opened_at || payload.openedAt || new Date().toISOString(), + claimed_by: payload.claimed_by || payload.claimedBy || "", + close_requested_by: payload.close_requested_by || payload.closeRequestedBy || "", + first_reply_at: payload.first_reply_at || payload.first_response_at || payload.firstReplyAt || "", + closed_at: payload.closed_at || payload.closedAt || "", + replace: payload.replace === true, + }; + + if (normalizedFallback.replace) { + resetSheetCache(); + } + + const results = await writeTicketSeedBatch( + request, + env, + spreadsheetId, + sheetTab, + records.map((entry) => normalizeTicketSeedEntry(entry, normalizedFallback)).filter(Boolean), + ); + + return jsonResponse(200, { + ok: true, + replaced: normalizedFallback.replace, + seeded: results.length, + results, + spreadsheet_id: spreadsheetId, + sheet_tab: sheetTab, }); + } catch (error) { + console.error("Ticket seed write failed", error); + const message = error instanceof Error ? error.message : "Failed to seed tickets"; + if (message.includes("permission denied")) { + return errorResponse(403, message); + } + if (message.includes("rate limit")) { + return errorResponse(429, message); + } + return errorResponse(502, message); } +} + +async function handleRequest(request, env, ctx) { + const url = new URL(request.url); + if (url.pathname === "/api/tickets" && request.method === "GET") { + if (!isValidAuthRequest(request, env)) { + return errorResponse(401, "Invalid auth key"); + } + + const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env); + + return jsonResponse(200, { + ok: true, + endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed"], + hasSpreadsheet: Boolean(spreadsheetId), + spreadsheet_id: spreadsheetId, + sheet_tab: sheetTab, + }); + } + + if (request.method !== "POST") { + return errorResponse(405, "Method Not Allowed"); + } + + if (url.pathname === "/validate-secrets") { + let secrets; + try { + secrets = await request.json(); + } catch { + return errorResponse(400, "Invalid request body"); + } + + if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) { + return errorResponse(400, "Request body must be a JSON object"); + } + + const accessCheck = await validateSecrets(env, secrets); + if (accessCheck) { + return accessCheck; + } + + return new Response(null, { status: 204 }); + } + + if (url.pathname === "/events") { + if (!isValidAuthRequest(request, env)) { + return errorResponse(401, "Invalid auth key"); + } - if (request.headers.get("X-Tickets-Auth") !== env.GOOGLEDOCS_AUTH_KEY) { - return jsonResponse(401, { error: "Invalid auth key" }); + return handleLifecycleEvent(request, env, ctx); + } + + if (url.pathname === "/api/tickets/bootstrap" || url.pathname === "/api/tickets/seed") { + if (!isValidAuthRequest(request, env)) { + return errorResponse(401, "Invalid auth key"); + } + + return handleTicketSeedRequest(request, env); + } + + if (url.pathname !== "/" && url.pathname !== "/tickets-webhook") { + return errorResponse(404, "Not Found"); + } + + if (!isValidAuthRequest(request, env)) { + return errorResponse(401, "Invalid auth key"); + } + + const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env); + if (!spreadsheetId) { + return errorResponse( + 400, + `Missing spreadsheet ID. Set X-Tickets-Google-Sheets-Id on the integration or GOOGLEDOCS_SPREADSHEET_ID in Cloudflare secrets.`, + ); } let body; try { body = await request.json(); } catch { - return jsonResponse(400, { error: "Invalid request body" }); + return errorResponse(400, "Invalid request body"); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return errorResponse(400, "Request body must be a JSON object"); + } + + // For the public webhook path `/tickets-webhook` we allow missing `user_id` + // because some integrations (e.g. channel-delete notifications) may not + // include a user identifier. For other endpoints, `user_id` is still + // required. + if (!body.user_id && url.pathname !== "/tickets-webhook") { + return errorResponse(400, "Missing user_id in request body"); + } + + const decision = await decideWithBotApi(request, env, body) + .then((result) => { + console.log( + `[DEBUG] Bot decision for channel ${body.ticket_channel_id || body.channel_id || body.channelId || "unknown"}: should_write=${result.shouldWrite}`, + ); + return result; + }) + .catch((error) => { + console.error("[DEBUG] Pre-write bot decision failed", error); + return { + shouldWrite: true, + ticket: buildBotSeedRecord(body), + }; + }); + + if (!decision.shouldWrite) { + return jsonResponse(200, { + ok: true, + skipped: "No meaningful bot-side state change", + spreadsheet_id: spreadsheetId, + sheet_tab: sheetTab, + }); + } + + const writeTask = processLifecycleEvent(decision.ticket, env, spreadsheetId, sheetTab) + .then((result) => { + console.log(`[DEBUG] Ticket webhook written for channel ${decision.ticket.ticket_channel_id || decision.ticket.channel_id || decision.ticket.channelId}: ${result.mode}`); + }) + .catch((error) => { + console.error("Google Sheets append failed", error); + }); + + if (ctx?.waitUntil) { + ctx.waitUntil(writeTask); } - // Placeholder response so the Worker can be deployed and wired before - // Google Sheets writing is implemented. - return jsonResponse(501, { - error: "Google Docs integration is not implemented yet", - received: body, + return jsonResponse(202, { + ok: true, + queued: true, + spreadsheet_id: spreadsheetId, + sheet_tab: sheetTab, }); } @@ -44,8 +1271,8 @@ export default Sentry.withSentry( sendDefaultPii: true, }), { - async fetch(request, env) { - return handleRequest(request, env); + async fetch(request, env, ctx) { + return handleRequest(request, env, ctx); }, }, ); diff --git a/googledocs/package-lock.json b/googledocs/package-lock.json new file mode 100644 index 0000000..0a0c240 --- /dev/null +++ b/googledocs/package-lock.json @@ -0,0 +1,1546 @@ +{ + "name": "googledocs-integration", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "googledocs-integration", + "dependencies": { + "@sentry/cloudflare": "^10.49.0" + }, + "devDependencies": { + "wrangler": "^4.83.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260421.1.tgz", + "integrity": "sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260421.1.tgz", + "integrity": "sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260421.1.tgz", + "integrity": "sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260421.1.tgz", + "integrity": "sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260421.1.tgz", + "integrity": "sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry/cloudflare": { + "version": "10.49.0", + "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.49.0.tgz", + "integrity": "sha512-kHNIwJ6SX39R5TRoW/Bf25rgrBwXBbD44fEK9+hkJ3IdGBLktXG2+T7mNGjpvR98TWxQDhcvs8WLfFw/SsDGrA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@sentry/core": "10.49.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.x" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@sentry/core": { + "version": "10.49.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.49.0.tgz", + "integrity": "sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260421.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260421.0.tgz", + "integrity": "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260421.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260421.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260421.1.tgz", + "integrity": "sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260421.1", + "@cloudflare/workerd-darwin-arm64": "1.20260421.1", + "@cloudflare/workerd-linux-64": "1.20260421.1", + "@cloudflare/workerd-linux-arm64": "1.20260421.1", + "@cloudflare/workerd-windows-64": "1.20260421.1" + } + }, + "node_modules/wrangler": { + "version": "4.84.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.84.1.tgz", + "integrity": "sha512-Xe1S/Bik7pNdtdJ+asHsEZC2dX9k3WxYn2BbxFtOrrLVxN/LKi750zsrjX41jSAk00M/O1l7jzyQV4sQqw8ftg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260421.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260421.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260421.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml index db55787..3e90e6f 100644 --- a/googledocs/wrangler.toml +++ b/googledocs/wrangler.toml @@ -12,4 +12,9 @@ SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13" # Secrets (set via `wrangler secret put `): # GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header # GOOGLEDOCS_SPREADSHEET_ID — target Google Sheets spreadsheet ID -# GOOGLEDOCS_SERVICE_ACCOUNT_JSON — service account JSON for Google APIs +# GOOGLE_SERVICE_ACCOUNT_EMAIL — Google service account email +# GOOGLE_PRIVATE_KEY — Google service account private key (\n escaped) +# RANKBLOX_TICKETS_API_SECRET — token sent to the RankBlox ticket bot API +# Optional vars/secrets: +# GOOGLEDOCS_SHEET_TAB — sheet tab to append to (default: Tickets) +# RANKBLOX_TICKETS_API_URL — base URL for the RankBlox ticket bot API From 70b71791a90c15478c71df5dc58db726d81bfcbf Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:03:14 -0400 Subject: [PATCH 3/9] add privacy policy Co-authored-by: Copilot --- googledocs/index.js | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/googledocs/index.js b/googledocs/index.js index 9d37cd7..702b8aa 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -10,6 +10,7 @@ const GOOGLE_WRITE_DEBOUNCE_MS = 750; const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500; const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url"; const BOT_API_SECRET_HEADER = "X-Tickets-Bot-Api-Secret"; +const PRIVACY_POLICY_PATHS = new Set(["/privacy", "/privacy-policy"]); const TICKET_COLUMNS = 11; const TICKET_HEADERS = [ "Ticket ID", @@ -46,6 +47,47 @@ function errorResponse(status, message) { return jsonResponse(status, { error: message }); } +function privacyPolicyResponse() { + const body = [ + "Privacy Policy", + "", + "Last updated: 2026-04-28", + "", + "This worker processes ticket lifecycle events for support tickets.", + "", + "Information used:", + "- Discord ticket and channel IDs", + "- Discord user IDs tied to ticket actions", + "- Guild IDs and ticket status details", + "- Timestamps for opened, claimed, unclaimed, first reply, close requested, and closed events", + "- Optional panel titles and spreadsheet routing settings", + "", + "How it is used:", + "- To record ticket activity", + "- To sync ticket status to Google Sheets", + "- To coordinate with the ticket bot and related worker services", + "", + "Sharing:", + "- Data is sent only to the configured Google Sheets spreadsheet and configured RankBlox ticket bot services needed to process the ticket", + "- The worker does not sell personal data", + "", + "Retention:", + "- Ticket records remain in Google Sheets until you delete them", + "- Local worker cache is temporary and may reset when the worker restarts", + "", + "Contact:", + "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com", + ].join("\n"); + + return new Response(body, { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }); +} + function getHeaderValue(request, name) { const value = request.headers.get(name); return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -1129,6 +1171,10 @@ async function handleTicketSeedRequest(request, env) { async function handleRequest(request, env, ctx) { const url = new URL(request.url); + if (PRIVACY_POLICY_PATHS.has(url.pathname) && (request.method === "GET" || request.method === "HEAD")) { + return privacyPolicyResponse(); + } + if (url.pathname === "/api/tickets" && request.method === "GET") { if (!isValidAuthRequest(request, env)) { return errorResponse(401, "Invalid auth key"); @@ -1138,7 +1184,7 @@ async function handleRequest(request, env, ctx) { return jsonResponse(200, { ok: true, - endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed"], + endpoints: ["/events", "/api/tickets/bootstrap", "/api/tickets/seed", "/privacy-policy"], hasSpreadsheet: Boolean(spreadsheetId), spreadsheet_id: spreadsheetId, sheet_tab: sheetTab, From 36af6e12eeeb7724547f03c057f89a7e426d0f17 Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Fri, 8 May 2026 10:30:47 -0400 Subject: [PATCH 4/9] Update privacy policy Co-authored-by: Copilot --- googledocs/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/googledocs/index.js b/googledocs/index.js index 702b8aa..2e5f73f 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -51,16 +51,16 @@ function privacyPolicyResponse() { const body = [ "Privacy Policy", "", - "Last updated: 2026-04-28", + "Last updated: 2026-05-08", "", - "This worker processes ticket lifecycle events for support tickets.", + "This api processes ticket lifecycle events for tickets.", "", "Information used:", "- Discord ticket and channel IDs", "- Discord user IDs tied to ticket actions", "- Guild IDs and ticket status details", - "- Timestamps for opened, claimed, unclaimed, first reply, close requested, and closed events", - "- Optional panel titles and spreadsheet routing settings", + "- Timestamps for opened, claimed, unclaimed, first reply, close requested, panel names, and closed events", + "- Optional spreadsheet routing settings", "", "How it is used:", "- To record ticket activity", @@ -73,7 +73,7 @@ function privacyPolicyResponse() { "", "Retention:", "- Ticket records remain in Google Sheets until you delete them", - "- Local worker cache is temporary and may reset when the worker restarts", + "- Local worker cache is temporary and does not persist across deployments or restarts", "", "Contact:", "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com", From b148e8e33470942c728c1fa1a6b692ffc39c8dc8 Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Fri, 8 May 2026 10:48:44 -0400 Subject: [PATCH 5/9] add a docs that explain everything. Co-authored-by: Copilot --- googledocs/docs.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 googledocs/docs.md diff --git a/googledocs/docs.md b/googledocs/docs.md new file mode 100644 index 0000000..6474a0f --- /dev/null +++ b/googledocs/docs.md @@ -0,0 +1,74 @@ +# Google Docs (Google Sheets) User Guide + +This guide is for users only. + +It assumes the integration is already live and connected by your admin/developer. You do not need to deploy anything. + +## What this integration does + +When ticket activity happens, the system writes or updates one row in your Google Sheet. + +Common ticket events that update the sheet: + +1. Ticket opened +2. Ticket claimed/unclaimed +3. First response sent +4. Close requested +5. Ticket closed + +## What you need to do as a user + +### 1. Setting up the Google Sheet + +1. Open or create a sheet, and get the id (the long string in the url) +2. Create/rename a tab, and keep this in mind (default `Tickets`). +3. Give the system editor access to the document. Email: rankblox-docs-bot@gmail-to-discord-340802.iam.gserviceaccount.com + +### 2. Confirm headers are present + +Your tickets tab should have these columns in row 1: + +1. Ticket ID +2. Channel ID +3. Status +4. Guild ID +5. User ID +6. Claimed By +7. Close Requested By +8. Opened At +9. First Reply At +10. Closed At +11. Panel Title + +The integration should write them automatically. + +### 3. Use your ticket system normally + +You do not manually push data to Google Sheets. + +Just use the ticket bot/panel as usual: + +1. Open a ticket +2. Claim or unclaim it +3. Reply to it +4. Request close or close it + +The sheet should update shortly after each action. + +### 4. Check if sync is working + +After you perform an action in tickets: + +1. Refresh the sheet. +2. Find the ticket by `Ticket ID` or `Channel ID`. +3. Confirm fields changed as expected: + - `Status` reflects the latest state (`open`, `claimed`, `close_requested`, `closed`, etc.) + - `First Reply At` fills after first staff reply + - `Closed At` fills when closed + - `Claimed By` and `Close Requested By` reflect user IDs from ticket actions + +### 5. Understand expected delay + +Small delay is normal. + +Writes are queued and debounced to reduce spam/duplicates, so updates can take a short moment to appear. \ No newline at end of file From 2198a208572935aebc7a22b7cf2455ecfeefa89b Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Fri, 8 May 2026 11:10:15 -0400 Subject: [PATCH 6/9] Edited after a full review of code --- googledocs/index.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/googledocs/index.js b/googledocs/index.js index 2e5f73f..5af4db8 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -5,7 +5,7 @@ const GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets"; const DEFAULT_SHEET_TAB = "Tickets"; const DEFAULT_SPREADSHEET_ID_HEADER = "X-Tickets-Google-Sheets-Id"; const DEFAULT_SHEET_TAB_HEADER = "X-Tickets-Google-Sheet-Tab"; -const GOOGLE_WRITE_MAX_ATTEMPTS = 2; +const GOOGLE_WRITE_MAX_ATTEMPTS = 5; const GOOGLE_WRITE_DEBOUNCE_MS = 750; const GOOGLE_WRITE_MIN_INTERVAL_MS = 1500; const BOT_API_URL_HEADER = "X-Tickets-Bot-Api-Url"; @@ -99,8 +99,7 @@ function normalizeConfiguredTicketValue(value) { function getSpreadsheetConfig(request, env) { const spreadsheetId = - getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER) || - readRequiredEnv(env, "GOOGLEDOCS_SPREADSHEET_ID"); + getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER) const sheetTab = getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) || env.GOOGLEDOCS_SHEET_TAB || @@ -110,8 +109,8 @@ function getSpreadsheetConfig(request, env) { } function getBotApiConfig(request, env) { - const botApiUrl = getHeaderValue(request, BOT_API_URL_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL"); - const botApiSecret = getHeaderValue(request, BOT_API_SECRET_HEADER) || readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET"); + const botApiUrl = readRequiredEnv(env, "RANKBLOX_TICKETS_API_URL"); + const botApiSecret = readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET"); return { botApiUrl, @@ -1258,10 +1257,7 @@ async function handleRequest(request, env, ctx) { return errorResponse(400, "Request body must be a JSON object"); } - // For the public webhook path `/tickets-webhook` we allow missing `user_id` - // because some integrations (e.g. channel-delete notifications) may not - // include a user identifier. For other endpoints, `user_id` is still - // required. + if (!body.user_id && url.pathname !== "/tickets-webhook") { return errorResponse(400, "Missing user_id in request body"); } From eebd2cb106799cb3533860b96e8bf51138930b3c Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Fri, 8 May 2026 11:34:55 -0400 Subject: [PATCH 7/9] Fixes to be compliant with guidelines Co-authored-by: Copilot --- googledocs/index.js | 57 ++++++++++++---------------------------- googledocs/wrangler.toml | 10 ++++++- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/googledocs/index.js b/googledocs/index.js index 5af4db8..2d6c160 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -141,28 +141,29 @@ function isValidAuthRequest(request, env) { } const provided = + getHeaderValue(request, "Authorization") || getHeaderValue(request, "X-Tickets-Auth") || getHeaderValue(request, "X-Tickets-Secret") || - getHeaderValue(request, "Authorization") || ""; const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided; return normalized === expected; } -async function validateSecrets(env, secrets) { - const spreadsheetId = secrets.spreadsheet_id || secrets.GOOGLEDOCS_SPREADSHEET_ID; - const sheetTab = secrets.sheet_tab || secrets.GOOGLEDOCS_SHEET_TAB || DEFAULT_SHEET_TAB; +async function validateSecrets(request, env) { + const spreadsheetId = getHeaderValue(request, DEFAULT_SPREADSHEET_ID_HEADER); + const sheetTabHeader = request.headers.get(DEFAULT_SHEET_TAB_HEADER); + const sheetTab = getHeaderValue(request, DEFAULT_SHEET_TAB_HEADER) || DEFAULT_SHEET_TAB; if (!spreadsheetId) { - return errorResponse(400, "Missing spreadsheet_id."); + return errorResponse(400, `Missing ${DEFAULT_SPREADSHEET_ID_HEADER} header.`); } if (!isSpreadsheetId(spreadsheetId)) { - return errorResponse(400, "Your spreadsheet_id is invalid."); + return errorResponse(400, "Invalid spreadsheet id format."); } - if (!isValidSheetTabName(sheetTab)) { - return errorResponse(400, "Your sheet_tab is invalid."); + if (sheetTabHeader && !isValidSheetTabName(sheetTab)) { + return errorResponse(400, "Invalid sheet tab name."); } return verifySpreadsheetAccess(env, spreadsheetId, sheetTab); @@ -872,10 +873,10 @@ async function verifySpreadsheetAccess(env, spreadsheetId, sheetTab) { } if (response.status === 429) { - return errorResponse(400, "Google Sheets rate limit exceeded. Try again shortly."); + return errorResponse(500, "Google Sheets rate limit exceeded while validating the spreadsheet. Try again shortly."); } - return errorResponse(400, "Unable to verify spreadsheet access."); + return errorResponse(500, "Google Sheets is temporarily unavailable while validating the spreadsheet."); } const data = await response.json(); @@ -1174,11 +1175,11 @@ async function handleRequest(request, env, ctx) { return privacyPolicyResponse(); } - if (url.pathname === "/api/tickets" && request.method === "GET") { - if (!isValidAuthRequest(request, env)) { - return errorResponse(401, "Invalid auth key"); - } + if (!isValidAuthRequest(request, env)) { + return errorResponse(401, "Invalid auth key"); + } + if (url.pathname === "/api/tickets" && request.method === "GET") { const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env); return jsonResponse(200, { @@ -1195,38 +1196,19 @@ async function handleRequest(request, env, ctx) { } if (url.pathname === "/validate-secrets") { - let secrets; - try { - secrets = await request.json(); - } catch { - return errorResponse(400, "Invalid request body"); - } - - if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) { - return errorResponse(400, "Request body must be a JSON object"); - } - - const accessCheck = await validateSecrets(env, secrets); + const accessCheck = await validateSecrets(request, env); if (accessCheck) { return accessCheck; } - return new Response(null, { status: 204 }); + return jsonResponse(200, {}); } if (url.pathname === "/events") { - if (!isValidAuthRequest(request, env)) { - return errorResponse(401, "Invalid auth key"); - } - return handleLifecycleEvent(request, env, ctx); } if (url.pathname === "/api/tickets/bootstrap" || url.pathname === "/api/tickets/seed") { - if (!isValidAuthRequest(request, env)) { - return errorResponse(401, "Invalid auth key"); - } - return handleTicketSeedRequest(request, env); } @@ -1234,10 +1216,6 @@ async function handleRequest(request, env, ctx) { return errorResponse(404, "Not Found"); } - if (!isValidAuthRequest(request, env)) { - return errorResponse(401, "Invalid auth key"); - } - const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env); if (!spreadsheetId) { return errorResponse( @@ -1257,7 +1235,6 @@ async function handleRequest(request, env, ctx) { return errorResponse(400, "Request body must be a JSON object"); } - if (!body.user_id && url.pathname !== "/tickets-webhook") { return errorResponse(400, "Missing user_id in request body"); } diff --git a/googledocs/wrangler.toml b/googledocs/wrangler.toml index 3e90e6f..d773699 100644 --- a/googledocs/wrangler.toml +++ b/googledocs/wrangler.toml @@ -8,6 +8,9 @@ enabled = true [vars] SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13" +#[[kv_namespaces]] +#binding = "INTEGRATION_CACHE" +#id = "fbdf23642f6a40d0b5876abf3265910d" # Secrets (set via `wrangler secret put `): # GOOGLEDOCS_AUTH_KEY — token expected in the X-Tickets-Auth header @@ -17,4 +20,9 @@ SENTRY_DSN = "https://f7d1718105706ab2e6f4402d4bc7b802@sentry.tkts.bot/13" # RANKBLOX_TICKETS_API_SECRET — token sent to the RankBlox ticket bot API # Optional vars/secrets: # GOOGLEDOCS_SHEET_TAB — sheet tab to append to (default: Tickets) -# RANKBLOX_TICKETS_API_URL — base URL for the RankBlox ticket bot API +# Integration request headers: +# Authorization: Bearer +# X-Tickets-Google-Sheets-Id: +# X-Tickets-Google-Sheet-Tab: +# X-Tickets-Bot-Api-Url: +# X-Tickets-Bot-Api-Secret: From 3fd2a62be1f07ec05883397410c9a15bff152a6f Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Fri, 8 May 2026 11:56:18 -0400 Subject: [PATCH 8/9] add comments Co-authored-by: Copilot --- googledocs/index.js | 52 +++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/googledocs/index.js b/googledocs/index.js index 2d6c160..f28cba6 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -127,9 +127,11 @@ function isSameOriginUrl(leftUrl, rightUrl) { } function getExpectedAuthKey(env) { + // Try multiple auth key sources in order of preference: + // 1. Integration-specific key (GOOGLEDOCS_AUTH_KEY) + // 2. Fallback to main API secret (RANKBLOX_TICKETS_API_SECRET) return ( readRequiredEnv(env, "GOOGLEDOCS_AUTH_KEY") || - readRequiredEnv(env, "TICKETS_SHARED_SECRET") || readRequiredEnv(env, "RANKBLOX_TICKETS_API_SECRET") ); } @@ -137,14 +139,16 @@ function getExpectedAuthKey(env) { function isValidAuthRequest(request, env) { const expected = getExpectedAuthKey(env); if (!expected) { - return true; + return false; // No auth configured, deny all requests } + // Check headers in order of precedence: Authorization (standard) > custom headers (legacy) const provided = getHeaderValue(request, "Authorization") || getHeaderValue(request, "X-Tickets-Auth") || getHeaderValue(request, "X-Tickets-Secret") || ""; + // Strip "Bearer " prefix if present const normalized = provided.startsWith("Bearer ") ? provided.slice("Bearer ".length) : provided; return normalized === expected; } @@ -179,28 +183,32 @@ function base64UrlEncodeText(text) { } function base64UrlEncodeBytes(bytes) { + // Convert bytes to binary string (required by btoa) let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } + // Base64-encode and convert to URL-safe format return btoa(binary) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); + .replace(/\+/g, "-") // + -> - for URL safety + .replace(/\//g, "_") // / -> _ for URL safety + .replace(/=+$/g, ""); // Remove padding } function pemPrivateKeyToArrayBuffer(pem) { + // Strip PEM formatting: remove line breaks, headers, and whitespace const stripped = pem .replace(/\r/g, "") .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace(/\s+/g, ""); + // Decode base64 to binary string, then convert to ArrayBuffer const binary = atob(stripped); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); + bytes[i] = binary.charCodeAt(i); // Convert char codes back to bytes } return bytes.buffer; } @@ -267,6 +275,8 @@ function buildSheetRange(sheetTab, rowNumber) { } function getTicketWriteKey(spreadsheetId, sheetTab, payload) { + // Generate unique queue key per ticket: spreadsheetId::sheetTab::ticketId/channelId + // Multiple requests for same ticket are coalesced into single queue entry const event = normaliseEventPayload(payload); const stableId = event.ticketId || event.channelId; if (!spreadsheetId || !sheetTab || !stableId) { @@ -313,10 +323,12 @@ function resetTicketWriteQueueState(key) { } async function runGoogleSheetsWrite(task) { + // Serialize all Google Sheets writes through a global chain to enforce minimum interval. + // Prevents hitting Google's rate limits (1500ms min interval between writes). const next = googleSheetsGlobalWriteChain.then(async () => { const elapsed = Date.now() - googleSheetsLastWriteAt; if (googleSheetsLastWriteAt > 0 && elapsed < GOOGLE_WRITE_MIN_INTERVAL_MS) { - await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed); + await sleep(GOOGLE_WRITE_MIN_INTERVAL_MS - elapsed); // Wait for rate limit window } const result = await task(); @@ -324,24 +336,29 @@ async function runGoogleSheetsWrite(task) { return result; }); + // Continue chain even if write fails (catch errors to prevent chain breakage) googleSheetsGlobalWriteChain = next.catch(() => null); return next; } async function flushTicketWriteQueue(key, env, spreadsheetId, sheetTab) { + // Process all pending ticket writes for this queue key. + // Deduplicates writes by comparing JSON signatures (same data = skip write). const state = googleSheetsWriteQueueByKey.get(key); if (!state || state.running) { - return null; + return null; // Already flushing or queue doesn't exist } state.running = true; let lastResult = { mode: "skipped", reason: "No pending ticket write" }; try { + // Process all payloads accumulated since last flush (handles rapid updates) while (state.pendingPayload) { const payload = state.pendingPayload; - state.pendingPayload = null; + state.pendingPayload = null; // Clear pending so new requests can accumulate + // Skip if ticket state hasn't changed (JSON signature is identical) const signature = getTicketWriteSignature(payload); if (signature === state.lastSignature) { lastResult = { mode: "skipped", reason: "Duplicate ticket state" }; @@ -420,18 +437,19 @@ function resetSheetCache() { } function parseRetryAfter(headerValue) { + // Retry-After header can be either seconds (number) or HTTP date if (!headerValue) { return null; } const seconds = Number(headerValue); if (Number.isFinite(seconds)) { - return Math.max(0, seconds * 1000); + return Math.max(0, seconds * 1000); // Convert seconds to milliseconds } const dateMs = Date.parse(headerValue); if (Number.isFinite(dateMs)) { - return Math.max(0, dateMs - Date.now()); + return Math.max(0, dateMs - Date.now()); // Calculate ms until the retry-after time } return null; @@ -762,6 +780,8 @@ async function ensureSheetsInitialized(env, spreadsheetId, sheetTab) { } async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) { + // Load all existing ticket rows from sheet and build lookup maps. + // A2:B means: start at row 2 (skip headers), columns A (ticket ID) and B (channel ID). const accessToken = await getGoogleAccessToken(env); const response = await fetch( `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetTab)}!A2:B`, @@ -781,10 +801,11 @@ async function loadSheetRowIndexCache(env, spreadsheetId, sheetTab) { const data = await response.json(); const rows = Array.isArray(data.values) ? data.values : []; + // Build maps: ticketId -> rowNumber and channelId -> rowNumber rows.forEach((row, index) => { const ticketId = row?.[0]; const channelId = row?.[1]; - const rowNumber = index + 2; + const rowNumber = index + 2; // +2: skip header row + convert 0-index to 1-index if (ticketId) { sheetRowByTicketId.set(String(ticketId), rowNumber); @@ -992,6 +1013,7 @@ async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) { if (response.ok) { const data = await response.json().catch(() => null); const updatedRange = data?.updates?.updatedRange || ""; + // Extract row number from updatedRange like "Sheet1!A5:K5" -> capture group 1 is "5" const match = updatedRange.match(/!(?:[A-Z]+)(\d+):/); if (match) { const rowNumber = Number(match[1]); @@ -1015,6 +1037,7 @@ async function upsertTicketToSheet(env, payload, spreadsheetId, sheetTab) { if (attempt < GOOGLE_WRITE_MAX_ATTEMPTS) { const retryAfterMs = parseRetryAfter(response.headers.get("retry-after")); + // Exponential backoff with 5s cap: 500ms, 1s, 2s, 4s, 5s (respect Retry-After if provided) const backoffMs = retryAfterMs ?? Math.min(5000, 500 * 2 ** (attempt - 1)); await sleep(backoffMs); } @@ -1180,6 +1203,7 @@ async function handleRequest(request, env, ctx) { } if (url.pathname === "/api/tickets" && request.method === "GET") { + // Note: getSpreadsheetConfig is synchronous but awaited for consistency const { spreadsheetId, sheetTab } = await getSpreadsheetConfig(request, env); return jsonResponse(200, { @@ -1239,6 +1263,8 @@ async function handleRequest(request, env, ctx) { return errorResponse(400, "Missing user_id in request body"); } + // Ask bot API if this ticket change is meaningful (e.g., don't write duplicate states). + // If bot API is down, assume write is needed (optimistic fallback). const decision = await decideWithBotApi(request, env, body) .then((result) => { console.log( @@ -1249,7 +1275,7 @@ async function handleRequest(request, env, ctx) { .catch((error) => { console.error("[DEBUG] Pre-write bot decision failed", error); return { - shouldWrite: true, + shouldWrite: true, // On error, default to writing (better than silently dropping) ticket: buildBotSeedRecord(body), }; }); From 80cced85dd9eae5c7708523b8ede934f89455c34 Mon Sep 17 00:00:00 2001 From: chillingcone426 <89105733+chillingcone426@users.noreply.github.com> Date: Sun, 10 May 2026 23:26:54 -0400 Subject: [PATCH 9/9] Fix privacy contact email --- googledocs/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/googledocs/index.js b/googledocs/index.js index f28cba6..e726641 100644 --- a/googledocs/index.js +++ b/googledocs/index.js @@ -76,7 +76,7 @@ function privacyPolicyResponse() { "- Local worker cache is temporary and does not persist across deployments or restarts", "", "Contact:", - "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.com", + "- For privacy questions, contact the developer @thebeston on Discord or via email at thebeston123@rankblox.xyz", ].join("\n"); return new Response(body, {