From 07cd642051d4027a5900072da6c685ec6fdf64d7 Mon Sep 17 00:00:00 2001 From: SatoshiQaziMuhammed Date: Thu, 22 Oct 2026 13:51:35 +0400 Subject: [PATCH] ops: run the Telegram bots and the assistant on vps3 (#46) * ops: run the Telegram bots and the assistant on vps3 telegram-bot and ask ran on the free cloud project, which paused itself after a quiet week; every bot went silent and the deploy job failed against the paused project. They now run in their own edge-runtime container on vps3 (bots/), from /opt/pezkuwi-bots. Separate from the shared functions runtime because that one verifies a JWT on every request, which Telegram cannot send, and holds the platform's wallet keys, which a public bot feeding user text to a language model should not sit next to. The new runtime holds only the keys the two functions read. - telegram-bot refuses updates without Telegram's secret-token header (TELEGRAM_WEBHOOK_SECRET); the main slot reads TELEGRAM_BOT_TOKEN_MAIN, since TELEGRAM_BOT_TOKEN on vps3 is the miniapp's sign-in check. - nginx keeps the public URL shape: supabase.pezkuwichain.io/functions/v1/{telegram-bot,ask}. - deploy.yml ships them with a new bots-deploy verb on the existing deploy key; the host script accepts exactly main, telegram-bot and ask, restarts the container and fails unless the bot comes back refusing unsigned updates. - Registry: _cloud_hosted becomes _hosted_elsewhere; the gate refuses those names in the shared volume and reports any copy there as stale. * ask: send the legacy service key as a bearer token on vps3 The vps3 gateway has no opaque sb_secret keys and honours the service_role JWT only as a bearer token; sent on apikey alone it reads as anon, the limiter's count fails, and every question is refused as rate-limited. --- .github/workflows/deploy.yml | 50 ++++++++++++------------ bots/docker-compose.yml | 37 ++++++++++++++++++ bots/main/index.ts | 39 ++++++++++++++++++ bots/nginx-locations.conf | 15 +++++++ eslint.config.js | 1 + ops/README.md | 49 +++++++++++++++++++---- ops/functions-registry.json | 20 ++++++---- ops/host/miniapp-functions-deploy | 36 ++++++++++++++++- ops/host/miniapp-functions-ssh | 5 ++- ops/supabase-deploy-functions | 19 +++++---- supabase/functions/ask/index.ts | 20 ++++++---- supabase/functions/telegram-bot/index.ts | 20 +++++++++- 12 files changed, 253 insertions(+), 58 deletions(-) create mode 100644 bots/docker-compose.yml create mode 100644 bots/main/index.ts create mode 100644 bots/nginx-locations.conf diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e4041d9..380eacd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,10 +63,8 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - # telegram-bot and ask are excluded on purpose: both Telegram bots and the - # news.pex.mom assistant are served from the cloud project, not from here. - # Stale copies of both still sit in this volume from before that split and - # take no traffic. They are deployed by the deploy-cloud-functions job. + # telegram-bot and ask are excluded on purpose: they run in their own + # runtime (bots/, deployed by the deploy-bots job), not in this volume. - name: Package functions run: | tar czf functions.tgz -C supabase \ @@ -96,30 +94,32 @@ jobs: fi ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 deploy < functions.tgz - # The two Telegram bots and the news.pex.mom assistant run on the cloud - # Supabase project, reached by webhook at - # vbhftvdayqfmcgmzdxfv.supabase.co/functions/v1/telegram-bot?bot=krd|dks. - # These were deployed by hand for months, which let the source in git drift - # behind what was actually running — badly enough that on 2026-07-21 the live - # function body had to be pulled back out of the deployed bundle to recover it. - deploy-cloud-functions: - name: Deploy edge functions (cloud) + # The Telegram bots' webhook (telegram-bot) and the assistant behind + # news.pex.mom and ick.pezkiwi.app (ask) run in their own runtime on vps3, + # /opt/pezkuwi-bots (bots/ here), reached at + # supabase.pezkuwichain.io/functions/v1/{telegram-bot,ask}. Until 2026-10-22 + # they ran on the free cloud project, which pauses itself after a quiet week + # and took every bot down with it. + deploy-bots: + name: Deploy bots runtime needs: deploy runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: Deploy telegram-bot and ask - env: - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} + - name: Package bots run: | - set -e - if [ -z "$SUPABASE_ACCESS_TOKEN" ]; then - echo "::error::SUPABASE_ACCESS_TOKEN is not set — the bots would silently keep running old code" - exit 1 - fi - for fn in telegram-bot ask; do - npx --yes supabase@latest functions deploy "$fn" \ - --project-ref vbhftvdayqfmcgmzdxfv \ - --no-verify-jwt - done + mkdir bundle + cp -r bots/main bundle/main + cp -r supabase/functions/telegram-bot supabase/functions/ask bundle/ + tar czf bots.tgz -C bundle ask main telegram-bot + + - name: Deploy to vps3 + env: + DEPLOY_KEY: ${{ secrets.MINIAPP_FUNCTIONS_DEPLOY_KEY }} + KNOWN_HOST: "217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ" + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$KNOWN_HOST" > "$RUNNER_TEMP/known_hosts" + ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 bots-deploy < bots.tgz diff --git a/bots/docker-compose.yml b/bots/docker-compose.yml new file mode 100644 index 0000000..e65263b --- /dev/null +++ b/bots/docker-compose.yml @@ -0,0 +1,37 @@ +# The bots runtime on vps3. Root installs this file at /opt/pezkuwi-bots/ and +# owns /opt/pezkuwi-bots/.env (600); CI only replaces the function code under +# /opt/pezkuwi-bots/functions/ (see ops/host/miniapp-functions-deploy). +# +# .env holds only what the two functions read: +# TELEGRAM_BOT_TOKEN_MAIN, TELEGRAM_BOT_TOKEN_KRD, TELEGRAM_BOT_TOKEN_DKS, +# TELEGRAM_WEBHOOK_SECRET, GEMINI_API_KEY, GROQ_API_KEY, ANTHROPIC_API_KEY, +# ASK_PROXY_KEY, SUPABASE_SECRET_KEYS ({"default":""}) +services: + bots: + container_name: pezkuwi-bots + image: supabase/edge-runtime:v1.71.2 + restart: unless-stopped + env_file: .env + environment: + # ask's per-IP limit writes to ai_chat_log through the local gateway. + SUPABASE_URL: http://kong:8000 + # Telegram cannot send a JWT; each function checks its own callers. + VERIFY_JWT: 'false' + volumes: + - ./functions:/home/deno/functions:ro + - deno-cache:/root/.cache/deno + # nginx (supabase.pezkuwichain.io) proxies /functions/v1/{telegram-bot,ask} here. + ports: + - '127.0.0.1:9060:9000' + mem_limit: 1g + networks: + - supabase + command: ['start', '--main-service', '/home/deno/functions/main'] + +networks: + supabase: + name: supabase_default + external: true + +volumes: + deno-cache: diff --git a/bots/main/index.ts b/bots/main/index.ts new file mode 100644 index 0000000..8cbc70d --- /dev/null +++ b/bots/main/index.ts @@ -0,0 +1,39 @@ +// Router for the bots runtime on vps3 (container pezkuwi-bots, /opt/pezkuwi-bots). +// +// It serves exactly two functions: telegram-bot (the webhook of all three +// Telegram bots) and ask (the assistant behind news.pex.mom and ick.pezkiwi.app). +// They live apart from the shared Supabase functions runtime for two reasons: +// that runtime verifies a JWT on every request and Telegram cannot send one, and +// it holds the platform's wallet keys, which a public bot that feeds user text +// to a language model has no business sitting next to. Each function +// authenticates its own callers: telegram-bot by Telegram's secret-token header, +// ask by its per-IP limit and proxy key. + +const SERVED = new Set(['telegram-bot', 'ask']); + +const json = (body: unknown, status: number) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +Deno.serve(async (req: Request) => { + const name = new URL(req.url).pathname.split('/')[1] || ''; + if (!SERVED.has(name)) return json({ msg: 'not found' }, 404); + + const env = Deno.env.toObject(); + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath: `/home/deno/functions/${name}`, + memoryLimitMb: 150, + workerTimeoutMs: 60_000, + noModuleCache: false, + importMapPath: null, + envVars: Object.keys(env).map((k) => [k, env[k]]), + }); + return await worker.fetch(req); + } catch (e) { + console.error(`[bots] ${name} failed to start:`, e); + return json({ msg: 'internal error' }, 500); + } +}); diff --git a/bots/nginx-locations.conf b/bots/nginx-locations.conf new file mode 100644 index 0000000..51adb70 --- /dev/null +++ b/bots/nginx-locations.conf @@ -0,0 +1,15 @@ +# Included inside the supabase.pezkuwichain.io server block, before `location /`, +# as /etc/nginx/snippets/pezkuwi-bots.conf. Keeps the public URLs the bots and +# callers already use, but sends these two names to the bots runtime instead of +# the shared functions gateway. +location ~ ^/functions/v1/(telegram-bot|ask)$ { + proxy_pass http://127.0.0.1:9060/$1$is_args$args; + proxy_http_version 1.1; + proxy_set_header Host $host; + # Set, not appended: ask rate-limits on the first address in this header, + # so a client must not be able to choose it. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 90s; + client_max_body_size 1m; +} diff --git a/eslint.config.js b/eslint.config.js index c9faaf5..eb35034 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -173,6 +173,7 @@ export default [ 'node_modules/**', 'coverage/**', 'supabase/functions/**', // Deno runtime, different globals + 'bots/**', // same edge runtime as supabase/functions '*.config.js', '*.config.ts', '.*.js', diff --git a/ops/README.md b/ops/README.md index 5fac4ec..ef09c77 100644 --- a/ops/README.md +++ b/ops/README.md @@ -47,12 +47,45 @@ than discovered a month later. Current ownership: 21 names to this project, 12 to pwap-web, 2 to the platform (`hello`, `main`). -`_cloud_hosted` lists functions that live on the **cloud** Supabase project -(`vbhftvdayqfmcgmzdxfv`), not on vps3: `telegram-bot` and `ask`. Both Telegram -bots reach the cloud project by webhook, and news.pex.mom's assistant calls `ask` -there. Stale copies of both sit in the vps3 volume from before that split and -serve no traffic — deploying them here updates a dead copy while the live one -keeps running whatever was last pushed by hand. +`_hosted_elsewhere` lists functions that are not served from this volume: +`telegram-bot` and `ask`, which run in their own runtime (next section). The gate +refuses to deploy them here, and the audit reports any copy in the volume as +stale. + +## The bots runtime (`bots/`) + +`telegram-bot` (the webhook of all three Telegram bots) and `ask` (the assistant +behind news.pex.mom and ick.pezkiwi.app) run in a separate edge-runtime container +on vps3, `pezkuwi-bots`, from `/opt/pezkuwi-bots`. Until 2026-10-22 they ran on +the free cloud project `vbhftvdayqfmcgmzdxfv`, which paused itself after a quiet +week and took every bot down with it. + +They are not in the shared runtime for two reasons. It verifies a JWT on every +request and Telegram cannot send one; and it holds the platform's wallet keys, +which a public bot that feeds user text to a language model should not sit next +to. This runtime holds only the keys the two functions read (listed in +`bots/docker-compose.yml`), and each function checks its own callers: +`telegram-bot` refuses any update without Telegram's secret-token header +(`TELEGRAM_WEBHOOK_SECRET`, set on every webhook with `secret_token`), `ask` +keeps its per-IP limit. + +nginx sends `supabase.pezkuwichain.io/functions/v1/{telegram-bot,ask}` to it +(`bots/nginx-locations.conf`), so the public URLs did not change shape. + +Install (root, on vps3), once: + +```bash +mkdir -p /opt/pezkuwi-bots/functions +install -m 644 bots/docker-compose.yml /opt/pezkuwi-bots/ +install -m 600 /dev/null /opt/pezkuwi-bots/.env # then fill it, values via stdin only +install -m 644 bots/nginx-locations.conf /etc/nginx/snippets/pezkuwi-bots.conf +# in the supabase.pezkuwichain.io server block, before `location /`: +# include /etc/nginx/snippets/pezkuwi-bots.conf; +``` + +CI then ships the code with `bots-deploy` (below): it replaces exactly `main`, +`telegram-bot` and `ask` under `/opt/pezkuwi-bots/functions`, restarts the +container, and fails unless `telegram-bot` comes back refusing an unsigned update. **pwap-web depends on this file too.** It is versioned here because this project owns the larger share and the gate was built for its incident, but a pwap-web @@ -84,7 +117,7 @@ is pinned in `authorized_keys` to one script here (`restrict,command=`). | key (secret) | forced command | input | |---|---|---| | `MINIAPP_SITE_DEPLOY_KEY` | `miniapp-site-receive` | site tar on stdin → `/var/www/telegram.pezkiwi.app`; removes earlier builds' assets after four hours, never the new build's | -| `MINIAPP_FUNCTIONS_DEPLOY_KEY` | `miniapp-functions-ssh` → sudo `miniapp-functions-deploy` | `gate-sha`, or `deploy` with functions.tgz on stdin | +| `MINIAPP_FUNCTIONS_DEPLOY_KEY` | `miniapp-functions-ssh` → sudo `miniapp-functions-deploy` | `gate-sha`; `deploy` with functions.tgz on stdin; `bots-deploy` with bots.tgz on stdin | Install (root, on vps3): @@ -93,7 +126,7 @@ install -o root -g root -m 755 ops/host/miniapp-* /usr/local/sbin/ useradd --system --create-home --shell /bin/bash miniapp-deploy chown -R miniapp-deploy:miniapp-deploy /var/www/telegram.pezkiwi.app # /etc/sudoers.d/miniapp-functions-deploy -miniapp-deploy ALL=(root) NOPASSWD: /usr/local/sbin/miniapp-functions-deploy deploy, /usr/local/sbin/miniapp-functions-deploy gate-sha +miniapp-deploy ALL=(root) NOPASSWD: /usr/local/sbin/miniapp-functions-deploy deploy, /usr/local/sbin/miniapp-functions-deploy gate-sha, /usr/local/sbin/miniapp-functions-deploy bots-deploy # ~miniapp-deploy/.ssh/authorized_keys restrict,command="/usr/local/sbin/miniapp-site-receive" ssh-ed25519 ... miniapp-ci-site-deploy restrict,command="/usr/local/sbin/miniapp-functions-ssh" ssh-ed25519 ... miniapp-ci-functions-deploy diff --git a/ops/functions-registry.json b/ops/functions-registry.json index be1c234..4fe7a04 100644 --- a/ops/functions-registry.json +++ b/ops/functions-registry.json @@ -2,7 +2,7 @@ "_comment": "Ownership registry for the shared self-hosted Supabase edge-function volume.", "_rule": "Every function directory under volumes/functions MUST be listed here with exactly one owning project. A project may only write the names it owns. Adding a new function requires adding it here FIRST.", "_enforced_by": "/usr/local/bin/supabase-deploy-functions", - "_updated": "2026-08-01", + "_updated": "2026-10-22", "projects": { "pezkuwi-telegram-miniapp": [ "accept-p2p-offer", @@ -40,14 +40,20 @@ "two-factor-auth", "verify-deposit" ], - "_platform": ["hello", "main"] - }, - "_cloud_hosted": { - "_comment": "These live on the CLOUD Supabase project vbhftvdayqfmcgmzdxfv (DKSapp), not here. Both Telegram bots and the news.pex.mom assistant are served from there. Copies may still sit in this volume from before the move - they are stale and serve no traffic. Do not deploy these here.", - "project": "vbhftvdayqfmcgmzdxfv", - "names": ["telegram-bot", "ask"] + "_platform": [ + "hello", + "main" + ] }, "_retired": { "email-verification": "pwap-web, 2026-08-01. Repodan 2026-07-30da silindi ama volume rsync --delete kullanmadigi icin sunucuda calismaya devam etti: anon key ile erisilebilir, service-role ile calisan, Access-Control-Allow-Origin * bir endpoint. send yolu zaten bozuktu (getUserByEmail SDKda yok) ve token tablosu bostu, yani somurulebilir degildi — ama bozuk olmasi koruyordu, tasarimi degil. /opt/supabase-self-hosted/retired-functions/ altinda karantinada." + }, + "_hosted_elsewhere": { + "_comment": "Served by the separate bots runtime on vps3 (/opt/pezkuwi-bots, container pezkuwi-bots), not from this volume: both Telegram bots' webhook and the assistant behind news.pex.mom and ick.pezkiwi.app. That runtime does not verify JWTs (Telegram cannot send one) and holds only the keys these two need. Copies in this volume are stale; retire them. Until 2026-10-22 these ran on the cloud project vbhftvdayqfmcgmzdxfv.", + "where": "/opt/pezkuwi-bots", + "names": [ + "telegram-bot", + "ask" + ] } } diff --git a/ops/host/miniapp-functions-deploy b/ops/host/miniapp-functions-deploy index 733aa74..c23a126 100755 --- a/ops/host/miniapp-functions-deploy +++ b/ops/host/miniapp-functions-deploy @@ -23,8 +23,42 @@ case "${1:-}" in [ -d "$BASE/functions" ] || { echo "miniapp-functions-deploy: the bundle has no functions/" >&2; exit 1; } "$GATE" --project pezkuwi-telegram-miniapp --src "$BASE/functions" --restart ;; + bots-deploy) + # telegram-bot and ask run in their own runtime (bots/ in the repository), + # not in the shared volume, so the ownership gate does not apply; this + # accepts exactly the three directories that runtime serves and nothing else. + # The runtime's compose file and .env are root's and never come from CI. + BOTS=/opt/pezkuwi-bots/functions + BASE=$(mktemp -d /opt/pezkuwi-bots/staging.XXXXXX) + trap 'rm -rf "$BASE"' EXIT + tar -xzf - -C "$BASE" --no-same-owner + got=$(find "$BASE" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort | tr '\n' ' ') + [ "$got" = "ask main telegram-bot " ] || { + echo "miniapp-functions-deploy: bots bundle must hold exactly ask, main, telegram-bot (got: $got)" >&2 + exit 1 + } + for d in ask main telegram-bot; do + [ -f "$BASE/$d/index.ts" ] || { echo "miniapp-functions-deploy: $d/index.ts missing" >&2; exit 1; } + done + chmod -R a+rX "$BASE" + for d in ask main telegram-bot; do + rm -rf "$BOTS/.$d.old" + [ -d "$BOTS/$d" ] && mv "$BOTS/$d" "$BOTS/.$d.old" + mv "$BASE/$d" "$BOTS/$d" + done + docker restart pezkuwi-bots >/dev/null + # Up means the router answers and telegram-bot loads far enough to refuse an + # update that lacks Telegram's secret header. + for _ in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:9060/telegram-bot || true) + [ "$code" = 401 ] && { for d in ask main telegram-bot; do rm -rf "$BOTS/.$d.old"; done; echo "bots runtime up"; exit 0; } + sleep 2 + done + echo "miniapp-functions-deploy: bots runtime did not come up (last answer: $code); previous code kept in $BOTS/..old" >&2 + exit 1 + ;; *) - echo "usage: miniapp-functions-deploy gate-sha|deploy" >&2 + echo "usage: miniapp-functions-deploy gate-sha|deploy|bots-deploy" >&2 exit 2 ;; esac diff --git a/ops/host/miniapp-functions-ssh b/ops/host/miniapp-functions-ssh index e262d48..d9459af 100755 --- a/ops/host/miniapp-functions-ssh +++ b/ops/host/miniapp-functions-ssh @@ -6,12 +6,13 @@ # # ssh miniapp-deploy@ gate-sha # sha256 of the installed gate and registry # ssh miniapp-deploy@ deploy < functions.tgz +# ssh miniapp-deploy@ bots-deploy < bots.tgz # telegram-bot + ask, own runtime # # miniapp-functions-deploy, which sudo lets this user run with exactly those # arguments and nothing else, does the work. set -euo pipefail case "${SSH_ORIGINAL_COMMAND:-}" in - deploy|gate-sha) exec sudo -n /usr/local/sbin/miniapp-functions-deploy "$SSH_ORIGINAL_COMMAND" ;; - *) echo "miniapp-functions-ssh: the commands are 'gate-sha' and 'deploy' (functions.tgz on stdin)" >&2; exit 2 ;; + deploy|gate-sha|bots-deploy) exec sudo -n /usr/local/sbin/miniapp-functions-deploy "$SSH_ORIGINAL_COMMAND" ;; + *) echo "miniapp-functions-ssh: the commands are 'gate-sha', 'deploy' (functions.tgz on stdin) and 'bots-deploy' (bots.tgz on stdin)" >&2; exit 2 ;; esac diff --git a/ops/supabase-deploy-functions b/ops/supabase-deploy-functions index 054871a..d23197d 100755 --- a/ops/supabase-deploy-functions +++ b/ops/supabase-deploy-functions @@ -34,6 +34,7 @@ import time from datetime import datetime, timezone REGISTRY = "/opt/supabase-self-hosted/functions-registry.json" +ELSEWHERE = "_hosted_elsewhere" VOLUME = "/opt/supabase-self-hosted/docker/volumes/functions" LOG = "/var/log/supabase-function-deploys.log" LOCK = "/var/lock/supabase-function-deploy.lock" @@ -68,12 +69,11 @@ def load_registry(): % (name, owner_of[name], project), 2) owner_of[name] = project - # Functions that were moved to the cloud project. They are declared, just not - # owned by anyone here — without this they read as unaccounted-for and the - # audit would offer to retire two live-looking directories. - cloud = data.get("_cloud_hosted") or {} - for name in cloud.get("names", []): - owner_of.setdefault(name, "_cloud_hosted") + # Functions served by another runtime, not from this volume. They are declared + # so no project can claim the name here, but nobody deploys them into it. + elsewhere = data.get("_hosted_elsewhere") or {} + for name in elsewhere.get("names", []): + owner_of.setdefault(name, ELSEWHERE) return projects, owner_of @@ -154,6 +154,10 @@ def audit(owner_of): """ disk, declared = on_disk(), set(owner_of) extra, missing = sorted(disk - declared), sorted(declared - disk) + # A copy of a function served elsewhere is never the live one; it is stale. + stale = sorted(n for n in disk if owner_of.get(n) == ELSEWHERE) + missing = [n for n in missing if owner_of.get(n) != ELSEWHERE] + extra = sorted(set(extra) | set(stale)) if extra: print("\nVolume holds %d function(s) the registry does not account for:" % len(extra)) @@ -178,7 +182,8 @@ def retire(names, project, owner_of): stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") for name in names: owner = owner_of.get(name) - if owner is not None and owner != project: + # A stale copy of a function served elsewhere belongs to no one here. + if owner is not None and owner not in (project, ELSEWHERE): fail("'%s' belongs to '%s' — refusing. Retire it from that project." % (name, owner)) if not os.path.isdir(os.path.join(VOLUME, name)): diff --git a/supabase/functions/ask/index.ts b/supabase/functions/ask/index.ts index b1620b0..4cf2fa0 100644 --- a/supabase/functions/ask/index.ts +++ b/supabase/functions/ask/index.ts @@ -9,9 +9,10 @@ const GROQ_API_KEY = Deno.env.get('GROQ_API_KEY') || ''; // record on our side instead. const PROXY_KEY = Deno.env.get('ASK_PROXY_KEY') || ''; const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || ''; -// The project's legacy anon/service_role keys are disabled (2026-10-02, after the -// service_role key leaked through a public commit). The secret key comes from -// SUPABASE_SECRET_KEYS, a JSON object keyed by name; `default` is the project's. +// The service key comes from SUPABASE_SECRET_KEYS, a JSON object keyed by name; +// `default` is used. On vps3 it is the self-hosted project's service_role JWT +// (that gateway has no opaque keys). The cloud project, where this ran until +// 2026-10-22, had its legacy keys disabled after one leaked and used sb_secret_…. const SERVICE_KEY = (() => { try { return JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS') || '{}')['default'] || ''; @@ -237,9 +238,14 @@ async function allowed(ip: string): Promise { // the key stopped working, every count came back 0 and every request went // through. if (!SUPABASE_URL || !SERVICE_KEY) return false; - // A secret key goes on the apikey header only. Sent as `Authorization: Bearer` - // too, the gateway tries to parse it as a JWT and rejects the request. - const h = { apikey: SERVICE_KEY, Prefer: 'count=exact' }; + // An opaque secret key (sb_secret_…) goes on the apikey header only: sent as + // `Authorization: Bearer` too, the gateway tries to parse it as a JWT and + // rejects it. The vps3 gateway has no opaque keys and takes the legacy + // service_role JWT, which it only honours as a bearer token. + const auth: Record = SERVICE_KEY.startsWith('eyJ') + ? { apikey: SERVICE_KEY, Authorization: `Bearer ${SERVICE_KEY}` } + : { apikey: SERVICE_KEY }; + const h = { ...auth, Prefer: 'count=exact' }; const cnt = async (sinceISO: string): Promise => { const u = `${SUPABASE_URL}/rest/v1/ai_chat_log?ip=eq.${encodeURIComponent(ip)}&created_at=gte.${sinceISO}&select=id`; const r = await fetch(u, { headers: { ...h, Range: '0-0' } }); @@ -252,7 +258,7 @@ async function allowed(ip: string): Promise { if ((await cnt(dayAgo)) >= 120) return false; // 120 / day const w = await fetch(`${SUPABASE_URL}/rest/v1/ai_chat_log`, { method: 'POST', - headers: { apikey: SERVICE_KEY, 'Content-Type': 'application/json' }, + headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ ip }), }); return w.ok; diff --git a/supabase/functions/telegram-bot/index.ts b/supabase/functions/telegram-bot/index.ts index 7523930..c4bf0c0 100644 --- a/supabase/functions/telegram-bot/index.ts +++ b/supabase/functions/telegram-bot/index.ts @@ -14,11 +14,25 @@ import { wordlist } from 'https://esm.sh/@scure/bip39@1.2.1/wordlists/english'; // ── Bot configuration ─────────────────────────────────────────────── const BOT_TOKENS: Record = { - main: Deno.env.get('TELEGRAM_BOT_TOKEN') || '', + // Not TELEGRAM_BOT_TOKEN: on vps3 that name is the miniapp's sign-in check. + main: Deno.env.get('TELEGRAM_BOT_TOKEN_MAIN') || '', krd: Deno.env.get('TELEGRAM_BOT_TOKEN_KRD') || '', dks: Deno.env.get('TELEGRAM_BOT_TOKEN_DKS') || '', }; +// Every webhook is registered with this as its secret_token, and Telegram sends +// it back on each update. Without the check anyone who knows the URL could post +// a forged update and have the bots message any chat. Unset means refuse all. +const WEBHOOK_SECRET = Deno.env.get('TELEGRAM_WEBHOOK_SECRET') || ''; + +function fromTelegram(req: Request): boolean { + const got = req.headers.get('x-telegram-bot-api-secret-token') || ''; + if (!WEBHOOK_SECRET || got.length !== WEBHOOK_SECRET.length) return false; + let diff = 0; + for (let i = 0; i < got.length; i++) diff |= got.charCodeAt(i) ^ WEBHOOK_SECRET.charCodeAt(i); + return diff === 0; +} + const MINI_APP_URLS: Record = { // @BizinikiwiBot, created fresh under separate ownership on 2026-07-30. It is // not a recovery of the compromised @pezkuwichain_bot — that account is gone @@ -831,6 +845,10 @@ serve(async (req: Request) => { return new Response('Method not allowed', { status: 405 }); } + if (!fromTelegram(req)) { + return new Response('Unauthorized', { status: 401 }); + } + try { const botId = getBotId(req); const botToken = BOT_TOKENS[botId] || BOT_TOKENS.main;