diff --git a/api/src/bindings.ts b/api/src/bindings.ts index be6cb42..96c365e 100644 --- a/api/src/bindings.ts +++ b/api/src/bindings.ts @@ -11,7 +11,8 @@ export interface Env { TICKET_SIGNING_SECRET: string; SESSION_SIGNING_SECRET: string; TELEGRAM_BOT_TOKEN: string; - TELEGRAM_BOT_USERNAME: string; + // Optional at runtime: deploy.sh skips empty secrets, so the binding may be absent + TELEGRAM_BOT_USERNAME?: string; TELEGRAM_ADMIN_CHAT_ID: string; EV_API_KEY: string; diff --git a/api/src/index.ts b/api/src/index.ts index 4627b39..2a35fb5 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -20,6 +20,7 @@ import { } from "./validation.js"; import type { SessionClaims, SupportedAlg, Identity } from "./types.js"; import { sendDailyDigest } from "./digest.js"; +import { buildTelegramDeepLink } from "./telegram.js"; type HonoEnv = { Bindings: Env; Variables: { auth: SessionClaims } }; @@ -158,11 +159,18 @@ app.post("/v1/auth/challenge", async (c) => { await store.resolveChallenge(challenge.id, body.telegram_user_id); } + const deepLink = botEnabled ? buildTelegramDeepLink(c.env.TELEGRAM_BOT_USERNAME, challenge.id) : null; + if (botEnabled && !deepLink) { + console.error( + "[auth] TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is missing or invalid — deep_link omitted. Set the bot username secret (without @).", + ); + } + return c.json( { challenge_id: challenge.id, expires_at: challenge.expires_at, - deep_link: botEnabled ? `https://t.me/${c.env.TELEGRAM_BOT_USERNAME.trim().replace(/^@/, "")}?start=${challenge.id}` : null, + deep_link: deepLink, }, 201, ); diff --git a/api/src/telegram.ts b/api/src/telegram.ts new file mode 100644 index 0000000..8cd3406 --- /dev/null +++ b/api/src/telegram.ts @@ -0,0 +1,24 @@ +// Telegram bot usernames: 5-32 chars, letters/digits/underscore, must start with a letter. +const USERNAME_RE = /^[A-Za-z][A-Za-z0-9_]{4,31}$/; + +/** + * Normalize a configured bot username into a t.me-routable form. + * Accepts values with a leading "@" or stray whitespace (common paste errors). + * Returns null when the value is missing or can't be a valid username — + * a null here must never be interpolated into a t.me URL, or the link + * routes to a nonexistent user (e.g. "https://t.me/undefined"). + */ +export function normalizeBotUsername(raw: string | undefined | null): string | null { + const username = (raw ?? "").trim().replace(/^@+/, ""); + return USERNAME_RE.test(username) ? username : null; +} + +/** Build the auth deep link, or null when the bot username is not usable. */ +export function buildTelegramDeepLink( + botUsername: string | undefined | null, + challengeId: string, +): string | null { + const username = normalizeBotUsername(botUsername); + if (!username) return null; + return `https://t.me/${username}?start=${challengeId}`; +} diff --git a/docs/runbook.md b/docs/runbook.md index 669025a..6fa6b92 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -273,6 +273,13 @@ npx wrangler kv key list --namespace-id= - Check Worker logs: `npx wrangler tail` - Re-set webhook URL if needed. +- **Auth deep link points to a nonexistent Telegram user** (e.g. `https://t.me/undefined?start=...`): + - The `TELEGRAM_BOT_USERNAME` secret is missing or invalid on the deployed Worker. `deploy.sh` used to silently skip empty secrets, so the binding could be absent while `TELEGRAM_BOT_TOKEN` was set. + - Check: `npx wrangler secret list --env production --config api/wrangler.toml` + - Fix: set it to the bot's username (the `..._bot` handle from @BotFather, **without** `@`, not the display name): + `echo "" | npx wrangler secret put TELEGRAM_BOT_USERNAME --env production --config api/wrangler.toml` + - The API now omits `deep_link` (returns `null`) and logs a warning instead of emitting a broken link; `deploy.sh` refuses to deploy a bot token without a valid username. + - **CORS errors on web UI**: verify `ALLOWED_ORIGINS` in `wrangler.toml` includes `https://pay.clw.cash`. ## Secrets Rotation diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c701d1e..9939284 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -35,6 +35,21 @@ require_var() { fi } +# The auth deep link is https://t.me/$TELEGRAM_BOT_USERNAME?start=... — deploying a +# bot token without a username produces links to a nonexistent Telegram user. +check_telegram_config() { + if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]]; then + # Normalize a pasted "@botname" to "botname" — t.me links need the bare username + TELEGRAM_BOT_USERNAME="${TELEGRAM_BOT_USERNAME#@}" + if [[ -z "${TELEGRAM_BOT_USERNAME:-}" ]]; then + die "TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is empty — auth deep links would point to https://t.me/undefined. Set TELEGRAM_BOT_USERNAME (without @) in $ENV_FILE" + fi + if [[ ! "$TELEGRAM_BOT_USERNAME" =~ ^[A-Za-z][A-Za-z0-9_]{4,31}$ ]]; then + die "TELEGRAM_BOT_USERNAME '$TELEGRAM_BOT_USERNAME' is not a valid Telegram username (5-32 chars, letters/digits/underscore, no @, no URL)" + fi + fi +} + # ── Generate secrets ───────────────────────────────────────────────── generate_secrets() { info "Generating fresh secrets..." @@ -153,6 +168,7 @@ deploy_api() { require_var TICKET_SIGNING_SECRET require_var SESSION_SIGNING_SECRET require_var EV_API_KEY + check_telegram_config # Set secrets on CF Worker info "Setting Worker secrets..." @@ -181,6 +197,7 @@ deploy_worker_api() { require_var TICKET_SIGNING_SECRET require_var SESSION_SIGNING_SECRET require_var WORKER_SEALING_KEY + check_telegram_config # Set secrets on CF Worker (no EV_API_KEY or INTERNAL_API_KEY needed) info "Setting Worker secrets..." diff --git a/test/telegramDeepLink.test.ts b/test/telegramDeepLink.test.ts new file mode 100644 index 0000000..119f71a --- /dev/null +++ b/test/telegramDeepLink.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { buildTelegramDeepLink, normalizeBotUsername } from "../api/src/telegram.js"; + +const CHALLENGE = "b97b9fe2-ec02-4a2c-871a-2690f328eb52"; + +describe("normalizeBotUsername", () => { + it("accepts a plain bot username", () => { + expect(normalizeBotUsername("clw_cash_bot")).toBe("clw_cash_bot"); + }); + + it("strips a leading @", () => { + expect(normalizeBotUsername("@clw_cash_bot")).toBe("clw_cash_bot"); + }); + + it("trims surrounding whitespace and newlines", () => { + expect(normalizeBotUsername(" clw_cash_bot\n")).toBe("clw_cash_bot"); + }); + + it("rejects undefined, null, and empty values", () => { + expect(normalizeBotUsername(undefined)).toBeNull(); + expect(normalizeBotUsername(null)).toBeNull(); + expect(normalizeBotUsername("")).toBeNull(); + expect(normalizeBotUsername(" ")).toBeNull(); + }); + + it("rejects values that are not valid Telegram usernames", () => { + expect(normalizeBotUsername("https://t.me/clw_cash_bot")).toBeNull(); + expect(normalizeBotUsername("Claw Cash Bot")).toBeNull(); + expect(normalizeBotUsername("1bot")).toBeNull(); // must start with a letter + expect(normalizeBotUsername("abcd")).toBeNull(); // too short + }); +}); + +describe("buildTelegramDeepLink", () => { + it("builds a t.me deep link with the start payload", () => { + expect(buildTelegramDeepLink("clw_cash_bot", CHALLENGE)).toBe( + `https://t.me/clw_cash_bot?start=${CHALLENGE}`, + ); + }); + + it("builds the same link from an @-prefixed username", () => { + expect(buildTelegramDeepLink("@clw_cash_bot", CHALLENGE)).toBe( + `https://t.me/clw_cash_bot?start=${CHALLENGE}`, + ); + }); + + it("returns null instead of a t.me/undefined link when the username binding is absent", () => { + expect(buildTelegramDeepLink(undefined, CHALLENGE)).toBeNull(); + }); + + it("returns null for an empty username", () => { + expect(buildTelegramDeepLink("", CHALLENGE)).toBeNull(); + }); +});