Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 9 additions & 1 deletion api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };

Expand Down Expand Up @@ -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,
);
Expand Down
24 changes: 24 additions & 0 deletions api/src/telegram.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
7 changes: 7 additions & 0 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ npx wrangler kv key list --namespace-id=<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 "<bot_username>" | 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
Expand Down
17 changes: 17 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down Expand Up @@ -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..."
Expand Down Expand Up @@ -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..."
Expand Down
54 changes: 54 additions & 0 deletions test/telegramDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading