fix(review): harden miniapp auth and finance flows

This commit is contained in:
2026-03-09 00:30:31 +04:00
parent 91a040f2ee
commit c8b17136be
22 changed files with 327 additions and 157 deletions

View File

@@ -1,27 +1,9 @@
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()
}
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
@@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => {
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, {
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
@@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => {
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, {
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan'
})
@@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => {
reason: 'not_member'
})
})
test('returns 400 for malformed JSON bodies', async () => {
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: '{"initData":'
})
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
ok: false,
error: 'Invalid JSON body'
})
})
test('does not reflect arbitrary origins in production without an allow-list', async () => {
const previousNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
try {
const authDate = Math.floor(Date.now() / 1000)
const auth = createMiniAppAuthHandler({
allowedOrigins: [],
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: 'https://unknown.example',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan'
})
})
})
)
expect(response.status).toBe(200)
expect(response.headers.get('access-control-allow-origin')).toBeNull()
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
}
})
})

View File

@@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string)
export function allowedMiniAppOrigin(
request: Request,
allowedOrigins: readonly string[]
allowedOrigins: readonly string[],
options: {
allowDynamicOrigin?: boolean
} = {}
): string | undefined {
const origin = request.headers.get('origin')
@@ -31,7 +34,8 @@ export function allowedMiniAppOrigin(
}
if (allowedOrigins.length === 0) {
return origin
const allowDynamicOrigin = options.allowDynamicOrigin ?? process.env.NODE_ENV !== 'production'
return allowDynamicOrigin ? origin : undefined
}
return allowedOrigins.includes(origin) ? origin : undefined
@@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise<string | nu
return null
}
const parsed = JSON.parse(text) as { initData?: string }
let parsed: { initData?: string }
try {
parsed = JSON.parse(text) as { initData?: string }
} catch {
throw new Error('Invalid JSON body')
}
const initData = parsed.initData?.trim()
return initData && initData.length > 0 ? initData : null
}
export function miniAppErrorResponse(error: unknown, origin?: string): Response {
const message = error instanceof Error ? error.message : 'Unknown mini app error'
if (message === 'Invalid JSON body') {
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
}
console.error(
JSON.stringify({
event: 'miniapp.request_failed',
error: message
})
)
return miniAppJsonResponse({ ok: false, error: 'Internal Server Error' }, 500, origin)
}
export interface MiniAppSessionResult {
authorized: boolean
reason?: 'not_member'
@@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: {
origin
)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
return miniAppErrorResponse(error, origin)
}
}
}

View File

@@ -1,28 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { createHmac } from 'node:crypto'
import { createFinanceCommandService } from '@household/application'
import type { FinanceRepository } from '@household/ports'
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
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()
}
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
function repository(
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
@@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => {
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildInitData('test-bot-token', authDate, {
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
@@ -144,4 +126,38 @@ describe('createMiniAppDashboardHandler', () => {
}
})
})
test('returns 400 for malformed JSON bodies', async () => {
const financeService = createFinanceCommandService(
repository({
id: 'member-1',
telegramUserId: '123456',
displayName: 'Stan',
isAdmin: true
})
)
const dashboard = createMiniAppDashboardHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
financeService
})
const response = await dashboard.handler(
new Request('http://localhost/api/miniapp/dashboard', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: '{"initData":'
})
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
ok: false,
error: 'Invalid JSON body'
})
})
})

View File

@@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application'
import {
allowedMiniAppOrigin,
createMiniAppSessionService,
miniAppErrorResponse,
miniAppJsonResponse,
readMiniAppInitData
} from './miniapp-auth'
@@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: {
origin
)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error'
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
return miniAppErrorResponse(error, origin)
}
}
}

View File

@@ -1,8 +1,6 @@
import { BillingPeriod } from '@household/domain'
import type { ReminderJobService } from '@household/application'
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
type ReminderType = (typeof REMINDER_TYPES)[number]
import { BillingPeriod } from '@household/domain'
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
interface ReminderJobRequestBody {
period?: string
@@ -42,8 +40,11 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
return {}
}
const parsed = JSON.parse(text) as ReminderJobRequestBody
return parsed
try {
return JSON.parse(text) as ReminderJobRequestBody
} catch {
throw new Error('Invalid JSON body')
}
}
export function createReminderJobsHandler(options: {

View File

@@ -57,11 +57,9 @@ export function createSchedulerRequestAuthorizer(options: {
return true
}
if (!oidcAudience || allowedEmails.size === 0) {
if (allowedEmails.size === 0) {
return false
}
}
try {
const audience = oidcAudience ?? new URL(request.url).origin

View File

@@ -1,30 +1,12 @@
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()
}
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
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), {
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456,
first_name: 'Stan',
username: 'stanislav'
@@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => {
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), {
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
id: 123456,
first_name: 'Stan'
})
@@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => {
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, {
const initData = buildMiniAppInitData(
'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()
})
test('rejects init data timestamps from the future', () => {
const now = new Date('2026-03-08T12:00:00.000Z')
const initData = buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000) + 5, {
id: 123456,
first_name: 'Stan'
})

View File

@@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData(
const authDateSeconds = Number(authDateRaw)
const nowSeconds = Math.floor(now.getTime() / 1000)
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) {
if (authDateSeconds > nowSeconds) {
return null
}
if (nowSeconds - authDateSeconds > maxAgeSeconds) {
return null
}

View File

@@ -0,0 +1,19 @@
import { createHmac } from 'node:crypto'
export function buildMiniAppInitData(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()
}

View File

@@ -107,7 +107,11 @@ function App() {
try {
setDashboard(await fetchMiniAppDashboard(initData))
} catch {
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to load mini app dashboard', error)
}
setDashboard(null)
}
} catch {

View File

@@ -131,14 +131,31 @@ button {
background: rgb(247 179 137 / 0.14);
}
.locale-switch__buttons button:focus-visible,
.nav-grid button:focus-visible,
.ghost-button:focus-visible {
outline: 2px solid #f7b389;
outline-offset: 2px;
border-color: rgb(247 179 137 / 0.7);
}
.hero-card,
.panel {
border: 1px solid rgb(255 255 255 / 0.1);
background-color: rgb(18 26 36 / 0.82);
background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02));
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 24px 64px rgb(0 0 0 / 0.22);
}
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.hero-card,
.panel {
background: rgb(18 26 36 / 0.94);
}
}
.hero-card {
margin-top: 28px;
border-radius: 28px;

View File

@@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
}
if (!response.ok) {
throw new Error(payload.error)
throw new Error(payload.error ?? 'Failed to create mini app session')
}
return {

View File

@@ -23,5 +23,9 @@ declare global {
}
export function getTelegramWebApp(): TelegramWebApp | undefined {
if (typeof window === 'undefined') {
return undefined
}
return window.Telegram?.WebApp
}

View File

@@ -48,8 +48,13 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
- `bot_household_id`
- `bot_household_chat_id`
- `bot_purchase_topic_id`
- optional `bot_feedback_topic_id`
- `bot_mini_app_allowed_origins`
- optional `bot_parser_model`
Set `bot_mini_app_allowed_origins` to the exact mini app origins you expect in each environment.
Do not rely on permissive origin reflection in production.
## Reminder jobs
Terraform provisions three separate Cloud Scheduler jobs:
@@ -67,6 +72,7 @@ They target the bot runtime endpoints:
Recommended rollout:
- keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply
- confirm `bot_mini_app_allowed_origins` is set for the environment before exposing the mini app
- validate job responses and logs
- unpause when the delivery side is ready
- disable dry-run only after production verification

View File

@@ -39,7 +39,7 @@ Build the first usable SolidJS mini app shell with a real Telegram initData veri
- Telegram initData is verified with the bot token before membership lookup.
- Mini app access depends on an actual household membership match.
- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; if unset, the endpoint falls back to permissive origin reflection for deployment simplicity.
- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; local development may use permissive origin reflection, but production must use an explicit allow-list.
## UX Notes

View File

@@ -1,10 +1,21 @@
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { createDbClient, schema } from '@household/db'
import type { AnonymousFeedbackRepository } from '@household/ports'
import type {
AnonymousFeedbackModerationStatus,
AnonymousFeedbackRepository
} from '@household/ports'
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
function parseModerationStatus(raw: string): AnonymousFeedbackModerationStatus {
if (raw === 'accepted' || raw === 'posted' || raw === 'rejected' || raw === 'failed') {
return raw
}
throw new Error(`Unexpected anonymous feedback moderation status: ${raw}`)
}
export function createDbAnonymousFeedbackRepository(
databaseUrl: string,
householdId: string
@@ -38,23 +49,10 @@ export function createDbAnonymousFeedbackRepository(
},
async getRateLimitSnapshot(memberId, acceptedSince) {
const countRows = await db
const rows = await db
.select({
count: sql<string>`count(*)`
})
.from(schema.anonymousMessages)
.where(
and(
eq(schema.anonymousMessages.householdId, householdId),
eq(schema.anonymousMessages.submittedByMemberId, memberId),
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES),
gte(schema.anonymousMessages.createdAt, acceptedSince)
)
)
const lastRows = await db
.select({
createdAt: schema.anonymousMessages.createdAt
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
})
.from(schema.anonymousMessages)
.where(
@@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository(
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
)
)
.orderBy(desc(schema.anonymousMessages.createdAt))
.limit(1)
return {
acceptedCountSince: Number(countRows[0]?.count ?? '0'),
lastAcceptedAt: lastRows[0]?.createdAt ?? null
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null
}
},
@@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository(
return {
submission: {
id: inserted[0].id,
moderationStatus: inserted[0].moderationStatus as
| 'accepted'
| 'posted'
| 'rejected'
| 'failed'
moderationStatus: parseModerationStatus(inserted[0].moderationStatus)
},
duplicate: false
}
@@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository(
return {
submission: {
id: row.id,
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
moderationStatus: parseModerationStatus(row.moderationStatus)
},
duplicate: true
}

View File

@@ -299,7 +299,8 @@ export function createDbFinanceRepository(
},
async replaceSettlementSnapshot(snapshot) {
const upserted = await db
await db.transaction(async (tx) => {
const upserted = await tx
.insert(schema.settlements)
.values({
householdId,
@@ -326,11 +327,15 @@ export function createDbFinanceRepository(
throw new Error('Failed to persist settlement snapshot')
}
await db
await tx
.delete(schema.settlementLines)
.where(eq(schema.settlementLines.settlementId, settlementId))
await db.insert(schema.settlementLines).values(
if (snapshot.lines.length === 0) {
return
}
await tx.insert(schema.settlementLines).values(
snapshot.lines.map((line) => ({
settlementId,
memberId: line.memberId,
@@ -341,6 +346,7 @@ export function createDbFinanceRepository(
explanations: line.explanations
}))
)
})
}
}

View File

@@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
await repository.openCycle(period, currency)
return {
id: '',
period,
currency
const cycle = await repository.getCycleByPeriod(period)
if (!cycle) {
throw new Error(`Failed to load billing cycle for period ${period}`)
}
return cycle
},
async closeCycle(periodArg) {

View File

@@ -44,6 +44,10 @@ describe('createReminderJobService', () => {
test('claims a dispatch once and returns the dedupe key', async () => {
const repository = new ReminderDispatchRepositoryStub()
repository.nextResult = {
dedupeKey: '2026-03:rent-due',
claimed: true
}
const service = createReminderJobService(repository)
const result = await service.handleJob({
@@ -53,6 +57,7 @@ describe('createReminderJobService', () => {
})
expect(result.status).toBe('claimed')
expect(result.dedupeKey).toBe('2026-03:rent-due')
expect(repository.lastClaim).toMatchObject({
householdId: 'household-1',
period: '2026-03',

View File

@@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
function buildReminderDedupeKey(period: string, reminderType: ReminderType): string {
return `${period}:${reminderType}`
}
function createReminderMessage(reminderType: ReminderType, period: string): string {
switch (reminderType) {
case 'utilities':
@@ -56,7 +60,7 @@ export function createReminderJobService(
if (input.dryRun === true) {
return {
status: 'dry-run',
dedupeKey: `${period}:${input.reminderType}`,
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
payloadHash,
reminderType: input.reminderType,
period,

View File

@@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL {
async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> {
const response = await fetch(url, init)
const text = await response.text()
const payload = (text.length > 0 ? JSON.parse(text) : null) as unknown
let payload: unknown = null
if (text.length > 0) {
try {
payload = JSON.parse(text) as unknown
} catch {
throw new Error(`${url.toString()} returned invalid JSON: ${text}`)
}
}
if (response.status !== expectedStatus) {
throw new Error(

View File

@@ -9,11 +9,21 @@ function requireEnv(name: string): string {
return value
}
async function telegramRequest(
function parseCommand(raw: string | undefined): WebhookCommand {
const command = raw?.trim() || 'info'
if (command === 'info' || command === 'set' || command === 'delete') {
return command
}
throw new Error(`Unsupported command: ${command}`)
}
async function telegramRequest<T>(
botToken: string,
method: string,
body?: URLSearchParams
): Promise<any> {
): Promise<T> {
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
method: body ? 'POST' : 'GET',
body
@@ -27,11 +37,11 @@ async function telegramRequest(
throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`)
}
return payload.result
return payload.result as T
}
async function run(): Promise<void> {
const command = (process.argv[2] ?? 'info') as WebhookCommand
const command = parseCommand(process.argv[2])
const botToken = requireEnv('TELEGRAM_BOT_TOKEN')
switch (command) {