feat(miniapp): add telegram-authenticated shell

This commit is contained in:
2026-03-08 22:30:59 +04:00
parent fd0680c8ef
commit f8478b717b
20 changed files with 1205 additions and 12 deletions

View File

@@ -9,6 +9,8 @@ export interface BotRuntimeConfig {
telegramPurchaseTopicId?: number
purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean
miniAppAllowedOrigins: readonly string[]
miniAppAuthEnabled: boolean
schedulerSharedSecret?: string
schedulerOidcAllowedEmails: readonly string[]
reminderJobsEnabled: boolean
@@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
const purchaseTopicIngestionEnabled =
databaseUrl !== undefined &&
@@ -83,6 +86,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled =
databaseUrl !== undefined &&
@@ -96,6 +100,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled,
financeCommandsEnabled,
miniAppAllowedOrigins,
miniAppAuthEnabled,
schedulerOidcAllowedEmails,
reminderJobsEnabled,
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'

View File

@@ -17,12 +17,21 @@ import {
import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server'
import { createMiniAppAuthHandler } from './miniapp-auth'
const runtime = getBotRuntimeConfig()
const bot = createTelegramBot(runtime.telegramBotToken)
const webhookHandler = webhookCallback(bot, 'std/http')
const shutdownTasks: Array<() => Promise<void>> = []
const financeRepositoryClient =
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
: null
if (financeRepositoryClient) {
shutdownTasks.push(financeRepositoryClient.close)
}
if (runtime.purchaseTopicIngestionEnabled) {
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
@@ -50,15 +59,10 @@ if (runtime.purchaseTopicIngestionEnabled) {
}
if (runtime.financeCommandsEnabled) {
const financeRepositoryClient = createDbFinanceRepository(
runtime.databaseUrl!,
runtime.householdId!
)
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
const financeService = createFinanceCommandService(financeRepositoryClient!.repository)
const financeCommands = createFinanceCommandsService(financeService)
financeCommands.register(bot)
shutdownTasks.push(financeRepositoryClient.close)
} else {
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
}
@@ -87,6 +91,13 @@ const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret,
webhookHandler,
miniAppAuth: financeRepositoryClient
? createMiniAppAuthHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
repository: financeRepositoryClient.repository
})
: undefined,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import type { FinanceRepository } from '@household/ports'
import { createMiniAppAuthHandler } from './miniapp-auth'
function buildInitData(botToken: string, authDate: number, user: object): string {
const params = new URLSearchParams()
params.set('auth_date', authDate.toString())
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
params.set('user', JSON.stringify(user))
const dataCheckString = [...params.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n')
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
params.set('hash', hash)
return params.toString()
}
function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
): FinanceRepository {
return {
getMemberByTelegramUserId: async () => member,
listMembers: async () => [],
getOpenCycle: async () => null,
getCycleByPeriod: async () => null,
getLatestCycle: async () => null,
openCycle: async () => {},
closeCycle: async () => {},
saveRentRule: async () => {},
addUtilityBill: async () => {},
getRentRuleForPeriod: async () => null,
getUtilityTotalForCycle: async () => 0n,
listParsedPurchasesForRange: async () => [],
replaceSettlementSnapshot: async () => {}
}
}
describe('createMiniAppAuthHandler', () => {
test('returns an authorized session for a household member', async () => {
const authDate = Math.floor(Date.now() / 1000)
const auth = createMiniAppAuthHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
repository: repository({
id: 'member-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
})
})
const response = await auth.handler(
new Request('http://localhost/api/miniapp/session', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
language_code: 'ru'
})
})
})
)
expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:5173')
expect(await response.json()).toMatchObject({
ok: true,
authorized: true,
member: {
displayName: 'Stan',
isAdmin: true
},
telegramUser: {
id: '123456',
firstName: 'Stan',
username: 'stanislav',
languageCode: 'ru'
}
})
})
test('returns membership gate failure for a non-member', async () => {
const authDate = Math.floor(Date.now() / 1000)
const auth = createMiniAppAuthHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
repository: repository(null)
})
const response = await auth.handler(
new Request('http://localhost/api/miniapp/session', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan'
})
})
})
)
expect(response.status).toBe(403)
expect(await response.json()).toEqual({
ok: true,
authorized: false,
reason: 'not_member'
})
})
})

View File

@@ -0,0 +1,117 @@
import type { FinanceRepository } from '@household/ports'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
function json(body: object, status = 200, origin?: string): Response {
const headers = new Headers({
'content-type': 'application/json; charset=utf-8'
})
if (origin) {
headers.set('access-control-allow-origin', origin)
headers.set('access-control-allow-methods', 'POST, OPTIONS')
headers.set('access-control-allow-headers', 'content-type')
headers.set('vary', 'origin')
}
return new Response(JSON.stringify(body), {
status,
headers
})
}
function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined {
const origin = request.headers.get('origin')
if (!origin) {
return undefined
}
if (allowedOrigins.length === 0) {
return origin
}
return allowedOrigins.includes(origin) ? origin : undefined
}
async function readInitData(request: Request): Promise<string | null> {
const text = await request.text()
if (text.trim().length === 0) {
return null
}
const parsed = JSON.parse(text) as { initData?: string }
const initData = parsed.initData?.trim()
return initData && initData.length > 0 ? initData : null
}
export function createMiniAppAuthHandler(options: {
allowedOrigins: readonly string[]
botToken: string
repository: FinanceRepository
}): {
handler: (request: Request) => Promise<Response>
} {
return {
handler: async (request) => {
const origin = allowedOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return json({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return json({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const initData = await readInitData(request)
if (!initData) {
return json({ ok: false, error: 'Missing initData' }, 400, origin)
}
const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken)
if (!telegramUser) {
return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin)
}
const member = await options.repository.getMemberByTelegramUserId(telegramUser.id)
if (!member) {
return json(
{
ok: true,
authorized: false,
reason: 'not_member'
},
403,
origin
)
}
return json(
{
ok: true,
authorized: true,
member: {
id: member.id,
displayName: member.displayName,
isAdmin: member.isAdmin
},
telegramUser,
features: {
balances: false,
ledger: false
}
},
200,
origin
)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
return json({ ok: false, error: message }, 400, origin)
}
}
}
}

View File

@@ -7,6 +7,15 @@ describe('createBotWebhookServer', () => {
webhookPath: '/webhook/telegram',
webhookSecret: 'secret-token',
webhookHandler: async () => new Response('ok', { status: 200 }),
miniAppAuth: {
handler: async () =>
new Response(JSON.stringify({ ok: true, authorized: true }), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
},
scheduler: {
authorize: async (request) =>
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
@@ -71,6 +80,21 @@ describe('createBotWebhookServer', () => {
expect(await response.text()).toBe('ok')
})
test('accepts mini app auth request', async () => {
const response = await server.fetch(
new Request('http://localhost/api/miniapp/session', {
method: 'POST',
body: JSON.stringify({ initData: 'payload' })
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true
})
})
test('rejects scheduler request with missing secret', async () => {
const response = await server.fetch(
new Request('http://localhost/jobs/reminder/utilities', {

View File

@@ -2,6 +2,12 @@ export interface BotWebhookServerOptions {
webhookPath: string
webhookSecret: string
webhookHandler: (request: Request) => Promise<Response> | Response
miniAppAuth?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
scheduler?:
| {
pathPrefix?: string
@@ -32,6 +38,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
const normalizedWebhookPath = options.webhookPath.startsWith('/')
? options.webhookPath
: `/${options.webhookPath}`
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
const schedulerPathPrefix = options.scheduler
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
: null
@@ -44,6 +51,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return json({ ok: true })
}
if (options.miniAppAuth && url.pathname === miniAppAuthPath) {
return await options.miniAppAuth.handler(request)
}
if (url.pathname !== normalizedWebhookPath) {
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
if (request.method !== 'POST') {

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
function buildInitData(botToken: string, authDate: number, user: object): string {
const params = new URLSearchParams()
params.set('auth_date', authDate.toString())
params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc')
params.set('user', JSON.stringify(user))
const dataCheckString = [...params.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n')
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
params.set('hash', hash)
return params.toString()
}
describe('verifyTelegramMiniAppInitData', () => {
test('verifies valid init data and extracts user payload', () => {
const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456,
first_name: 'Stan',
username: 'stanislav'
})
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now)
expect(result).toEqual({
id: '123456',
firstName: 'Stan',
lastName: null,
username: 'stanislav',
languageCode: null
})
})
test('rejects invalid hash', () => {
const now = new Date('2026-03-08T12:00:00.000Z')
const params = new URLSearchParams(
buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456,
first_name: 'Stan'
})
)
params.set('hash', '0'.repeat(64))
const result = verifyTelegramMiniAppInitData(params.toString(), 'test-bot-token', now)
expect(result).toBeNull()
})
test('rejects expired init data', () => {
const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, {
id: 123456,
first_name: 'Stan'
})
const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600)
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,85 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
interface TelegramUserPayload {
id: number
first_name?: string
last_name?: string
username?: string
language_code?: string
}
export interface VerifiedMiniAppUser {
id: string
firstName: string | null
lastName: string | null
username: string | null
languageCode: string | null
}
export function verifyTelegramMiniAppInitData(
initData: string,
botToken: string,
now = new Date(),
maxAgeSeconds = 3600
): VerifiedMiniAppUser | null {
const params = new URLSearchParams(initData)
const hash = params.get('hash')
if (!hash) {
return null
}
const authDateRaw = params.get('auth_date')
if (!authDateRaw || !/^\d+$/.test(authDateRaw)) {
return null
}
const authDateSeconds = Number(authDateRaw)
const nowSeconds = Math.floor(now.getTime() / 1000)
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) {
return null
}
const userRaw = params.get('user')
if (!userRaw) {
return null
}
const payloadEntries = [...params.entries()]
.filter(([key]) => key !== 'hash')
.sort(([left], [right]) => left.localeCompare(right))
const dataCheckString = payloadEntries.map(([key, value]) => `${key}=${value}`).join('\n')
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest()
const expectedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex')
const expectedBuffer = Buffer.from(expectedHash, 'hex')
const actualBuffer = Buffer.from(hash, 'hex')
if (expectedBuffer.length !== actualBuffer.length) {
return null
}
if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
return null
}
let parsedUser: TelegramUserPayload
try {
parsedUser = JSON.parse(userRaw) as TelegramUserPayload
} catch {
return null
}
if (!Number.isInteger(parsedUser.id) || parsedUser.id <= 0) {
return null
}
return {
id: parsedUser.id.toString(),
firstName: parsedUser.first_name?.trim() || null,
lastName: parsedUser.last_name?.trim() || null,
username: parsedUser.username?.trim() || null,
languageCode: parsedUser.language_code?.trim() || null
}
}