mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
fix(review): harden miniapp auth and finance flows
This commit is contained in:
@@ -1,27 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { createHmac } from 'node:crypto'
|
|
||||||
|
|
||||||
import type { FinanceRepository } from '@household/ports'
|
import type { FinanceRepository } from '@household/ports'
|
||||||
|
|
||||||
import { createMiniAppAuthHandler } from './miniapp-auth'
|
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||||
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
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(
|
function repository(
|
||||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||||
@@ -66,7 +48,7 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
initData: buildInitData('test-bot-token', authDate, {
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan',
|
first_name: 'Stan',
|
||||||
username: 'stanislav',
|
username: 'stanislav',
|
||||||
@@ -114,7 +96,7 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
initData: buildInitData('test-bot-token', authDate, {
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
})
|
||||||
@@ -129,4 +111,73 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
reason: 'not_member'
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export function miniAppJsonResponse(body: object, status = 200, origin?: string)
|
|||||||
|
|
||||||
export function allowedMiniAppOrigin(
|
export function allowedMiniAppOrigin(
|
||||||
request: Request,
|
request: Request,
|
||||||
allowedOrigins: readonly string[]
|
allowedOrigins: readonly string[],
|
||||||
|
options: {
|
||||||
|
allowDynamicOrigin?: boolean
|
||||||
|
} = {}
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const origin = request.headers.get('origin')
|
const origin = request.headers.get('origin')
|
||||||
|
|
||||||
@@ -31,7 +34,8 @@ export function allowedMiniAppOrigin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allowedOrigins.length === 0) {
|
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
|
return allowedOrigins.includes(origin) ? origin : undefined
|
||||||
@@ -44,12 +48,35 @@ export async function readMiniAppInitData(request: Request): Promise<string | nu
|
|||||||
return null
|
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()
|
const initData = parsed.initData?.trim()
|
||||||
|
|
||||||
return initData && initData.length > 0 ? initData : null
|
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 {
|
export interface MiniAppSessionResult {
|
||||||
authorized: boolean
|
authorized: boolean
|
||||||
reason?: 'not_member'
|
reason?: 'not_member'
|
||||||
@@ -163,8 +190,7 @@ export function createMiniAppAuthHandler(options: {
|
|||||||
origin
|
origin
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown mini app auth error'
|
return miniAppErrorResponse(error, origin)
|
||||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { createHmac } from 'node:crypto'
|
|
||||||
|
|
||||||
import { createFinanceCommandService } from '@household/application'
|
import { createFinanceCommandService } from '@household/application'
|
||||||
import type { FinanceRepository } from '@household/ports'
|
import type { FinanceRepository } from '@household/ports'
|
||||||
|
|
||||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
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(
|
function repository(
|
||||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||||
@@ -106,7 +88,7 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
initData: buildInitData('test-bot-token', authDate, {
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan',
|
first_name: 'Stan',
|
||||||
username: 'stanislav',
|
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'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FinanceCommandService } from '@household/application'
|
|||||||
import {
|
import {
|
||||||
allowedMiniAppOrigin,
|
allowedMiniAppOrigin,
|
||||||
createMiniAppSessionService,
|
createMiniAppSessionService,
|
||||||
|
miniAppErrorResponse,
|
||||||
miniAppJsonResponse,
|
miniAppJsonResponse,
|
||||||
readMiniAppInitData
|
readMiniAppInitData
|
||||||
} from './miniapp-auth'
|
} from './miniapp-auth'
|
||||||
@@ -98,8 +99,7 @@ export function createMiniAppDashboardHandler(options: {
|
|||||||
origin
|
origin
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown mini app dashboard error'
|
return miniAppErrorResponse(error, origin)
|
||||||
return miniAppJsonResponse({ ok: false, error: message }, 400, origin)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { BillingPeriod } from '@household/domain'
|
|
||||||
import type { ReminderJobService } from '@household/application'
|
import type { ReminderJobService } from '@household/application'
|
||||||
|
import { BillingPeriod } from '@household/domain'
|
||||||
const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
|
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
||||||
type ReminderType = (typeof REMINDER_TYPES)[number]
|
|
||||||
|
|
||||||
interface ReminderJobRequestBody {
|
interface ReminderJobRequestBody {
|
||||||
period?: string
|
period?: string
|
||||||
@@ -42,8 +40,11 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(text) as ReminderJobRequestBody
|
try {
|
||||||
return parsed
|
return JSON.parse(text) as ReminderJobRequestBody
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON body')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReminderJobsHandler(options: {
|
export function createReminderJobsHandler(options: {
|
||||||
|
|||||||
@@ -57,10 +57,8 @@ export function createSchedulerRequestAuthorizer(options: {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oidcAudience || allowedEmails.size === 0) {
|
if (allowedEmails.size === 0) {
|
||||||
if (allowedEmails.size === 0) {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,30 +1,12 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { createHmac } from 'node:crypto'
|
|
||||||
|
|
||||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||||
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
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', () => {
|
describe('verifyTelegramMiniAppInitData', () => {
|
||||||
test('verifies valid init data and extracts user payload', () => {
|
test('verifies valid init data and extracts user payload', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
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,
|
id: 123456,
|
||||||
first_name: 'Stan',
|
first_name: 'Stan',
|
||||||
username: 'stanislav'
|
username: 'stanislav'
|
||||||
@@ -44,7 +26,7 @@ describe('verifyTelegramMiniAppInitData', () => {
|
|||||||
test('rejects invalid hash', () => {
|
test('rejects invalid hash', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
const now = new Date('2026-03-08T12:00:00.000Z')
|
||||||
const params = new URLSearchParams(
|
const params = new URLSearchParams(
|
||||||
buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
buildMiniAppInitData('test-bot-token', Math.floor(now.getTime() / 1000), {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
})
|
||||||
@@ -58,7 +40,23 @@ describe('verifyTelegramMiniAppInitData', () => {
|
|||||||
|
|
||||||
test('rejects expired init data', () => {
|
test('rejects expired init data', () => {
|
||||||
const now = new Date('2026-03-08T12:00:00.000Z')
|
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,
|
id: 123456,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function verifyTelegramMiniAppInitData(
|
|||||||
|
|
||||||
const authDateSeconds = Number(authDateRaw)
|
const authDateSeconds = Number(authDateRaw)
|
||||||
const nowSeconds = Math.floor(now.getTime() / 1000)
|
const nowSeconds = Math.floor(now.getTime() / 1000)
|
||||||
if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) {
|
if (authDateSeconds > nowSeconds) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowSeconds - authDateSeconds > maxAgeSeconds) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal file
19
apps/bot/src/telegram-miniapp-test-helpers.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -107,7 +107,11 @@ function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setDashboard(await fetchMiniAppDashboard(initData))
|
setDashboard(await fetchMiniAppDashboard(initData))
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Failed to load mini app dashboard', error)
|
||||||
|
}
|
||||||
|
|
||||||
setDashboard(null)
|
setDashboard(null)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -131,14 +131,31 @@ button {
|
|||||||
background: rgb(247 179 137 / 0.14);
|
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,
|
.hero-card,
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid rgb(255 255 255 / 0.1);
|
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));
|
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);
|
backdrop-filter: blur(16px);
|
||||||
box-shadow: 0 24px 64px rgb(0 0 0 / 0.22);
|
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 {
|
.hero-card {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise<MiniAppSess
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error)
|
throw new Error(payload.error ?? 'Failed to create mini app session')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -23,5 +23,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTelegramWebApp(): TelegramWebApp | undefined {
|
export function getTelegramWebApp(): TelegramWebApp | undefined {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return window.Telegram?.WebApp
|
return window.Telegram?.WebApp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,13 @@ Keep bot runtime config that is not secret in your `*.tfvars` file:
|
|||||||
- `bot_household_id`
|
- `bot_household_id`
|
||||||
- `bot_household_chat_id`
|
- `bot_household_chat_id`
|
||||||
- `bot_purchase_topic_id`
|
- `bot_purchase_topic_id`
|
||||||
|
- optional `bot_feedback_topic_id`
|
||||||
|
- `bot_mini_app_allowed_origins`
|
||||||
- optional `bot_parser_model`
|
- 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
|
## Reminder jobs
|
||||||
|
|
||||||
Terraform provisions three separate Cloud Scheduler jobs:
|
Terraform provisions three separate Cloud Scheduler jobs:
|
||||||
@@ -67,6 +72,7 @@ They target the bot runtime endpoints:
|
|||||||
Recommended rollout:
|
Recommended rollout:
|
||||||
|
|
||||||
- keep `scheduler_paused = true` and `scheduler_dry_run = true` on first apply
|
- 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
|
- validate job responses and logs
|
||||||
- unpause when the delivery side is ready
|
- unpause when the delivery side is ready
|
||||||
- disable dry-run only after production verification
|
- disable dry-run only after production verification
|
||||||
|
|||||||
@@ -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.
|
- Telegram initData is verified with the bot token before membership lookup.
|
||||||
- Mini app access depends on an actual household membership match.
|
- 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
|
## UX Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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
|
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(
|
export function createDbAnonymousFeedbackRepository(
|
||||||
databaseUrl: string,
|
databaseUrl: string,
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -38,23 +49,10 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getRateLimitSnapshot(memberId, acceptedSince) {
|
async getRateLimitSnapshot(memberId, acceptedSince) {
|
||||||
const countRows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
count: sql<string>`count(*)`
|
acceptedCountSince: sql<string>`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`,
|
||||||
})
|
lastAcceptedAt: sql<Date | null>`max(${schema.anonymousMessages.createdAt})`
|
||||||
.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
|
|
||||||
})
|
})
|
||||||
.from(schema.anonymousMessages)
|
.from(schema.anonymousMessages)
|
||||||
.where(
|
.where(
|
||||||
@@ -64,12 +62,10 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
|
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(schema.anonymousMessages.createdAt))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
acceptedCountSince: Number(countRows[0]?.count ?? '0'),
|
acceptedCountSince: Number(rows[0]?.acceptedCountSince ?? '0'),
|
||||||
lastAcceptedAt: lastRows[0]?.createdAt ?? null
|
lastAcceptedAt: rows[0]?.lastAcceptedAt ?? null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -99,11 +95,7 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
return {
|
return {
|
||||||
submission: {
|
submission: {
|
||||||
id: inserted[0].id,
|
id: inserted[0].id,
|
||||||
moderationStatus: inserted[0].moderationStatus as
|
moderationStatus: parseModerationStatus(inserted[0].moderationStatus)
|
||||||
| 'accepted'
|
|
||||||
| 'posted'
|
|
||||||
| 'rejected'
|
|
||||||
| 'failed'
|
|
||||||
},
|
},
|
||||||
duplicate: false
|
duplicate: false
|
||||||
}
|
}
|
||||||
@@ -131,7 +123,7 @@ export function createDbAnonymousFeedbackRepository(
|
|||||||
return {
|
return {
|
||||||
submission: {
|
submission: {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
|
moderationStatus: parseModerationStatus(row.moderationStatus)
|
||||||
},
|
},
|
||||||
duplicate: true
|
duplicate: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,48 +299,54 @@ export function createDbFinanceRepository(
|
|||||||
},
|
},
|
||||||
|
|
||||||
async replaceSettlementSnapshot(snapshot) {
|
async replaceSettlementSnapshot(snapshot) {
|
||||||
const upserted = await db
|
await db.transaction(async (tx) => {
|
||||||
.insert(schema.settlements)
|
const upserted = await tx
|
||||||
.values({
|
.insert(schema.settlements)
|
||||||
householdId,
|
.values({
|
||||||
cycleId: snapshot.cycleId,
|
householdId,
|
||||||
inputHash: snapshot.inputHash,
|
cycleId: snapshot.cycleId,
|
||||||
totalDueMinor: snapshot.totalDueMinor,
|
|
||||||
currency: snapshot.currency,
|
|
||||||
metadata: snapshot.metadata
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [schema.settlements.cycleId],
|
|
||||||
set: {
|
|
||||||
inputHash: snapshot.inputHash,
|
inputHash: snapshot.inputHash,
|
||||||
totalDueMinor: snapshot.totalDueMinor,
|
totalDueMinor: snapshot.totalDueMinor,
|
||||||
currency: snapshot.currency,
|
currency: snapshot.currency,
|
||||||
computedAt: new Date(),
|
|
||||||
metadata: snapshot.metadata
|
metadata: snapshot.metadata
|
||||||
}
|
})
|
||||||
})
|
.onConflictDoUpdate({
|
||||||
.returning({ id: schema.settlements.id })
|
target: [schema.settlements.cycleId],
|
||||||
|
set: {
|
||||||
|
inputHash: snapshot.inputHash,
|
||||||
|
totalDueMinor: snapshot.totalDueMinor,
|
||||||
|
currency: snapshot.currency,
|
||||||
|
computedAt: new Date(),
|
||||||
|
metadata: snapshot.metadata
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning({ id: schema.settlements.id })
|
||||||
|
|
||||||
const settlementId = upserted[0]?.id
|
const settlementId = upserted[0]?.id
|
||||||
if (!settlementId) {
|
if (!settlementId) {
|
||||||
throw new Error('Failed to persist settlement snapshot')
|
throw new Error('Failed to persist settlement snapshot')
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await tx
|
||||||
.delete(schema.settlementLines)
|
.delete(schema.settlementLines)
|
||||||
.where(eq(schema.settlementLines.settlementId, settlementId))
|
.where(eq(schema.settlementLines.settlementId, settlementId))
|
||||||
|
|
||||||
await db.insert(schema.settlementLines).values(
|
if (snapshot.lines.length === 0) {
|
||||||
snapshot.lines.map((line) => ({
|
return
|
||||||
settlementId,
|
}
|
||||||
memberId: line.memberId,
|
|
||||||
rentShareMinor: line.rentShareMinor,
|
await tx.insert(schema.settlementLines).values(
|
||||||
utilityShareMinor: line.utilityShareMinor,
|
snapshot.lines.map((line) => ({
|
||||||
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
settlementId,
|
||||||
netDueMinor: line.netDueMinor,
|
memberId: line.memberId,
|
||||||
explanations: line.explanations
|
rentShareMinor: line.rentShareMinor,
|
||||||
}))
|
utilityShareMinor: line.utilityShareMinor,
|
||||||
)
|
purchaseOffsetMinor: line.purchaseOffsetMinor,
|
||||||
|
netDueMinor: line.netDueMinor,
|
||||||
|
explanations: line.explanations
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,11 +232,12 @@ export function createFinanceCommandService(repository: FinanceRepository): Fina
|
|||||||
|
|
||||||
await repository.openCycle(period, currency)
|
await repository.openCycle(period, currency)
|
||||||
|
|
||||||
return {
|
const cycle = await repository.getCycleByPeriod(period)
|
||||||
id: '',
|
if (!cycle) {
|
||||||
period,
|
throw new Error(`Failed to load billing cycle for period ${period}`)
|
||||||
currency
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cycle
|
||||||
},
|
},
|
||||||
|
|
||||||
async closeCycle(periodArg) {
|
async closeCycle(periodArg) {
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ describe('createReminderJobService', () => {
|
|||||||
|
|
||||||
test('claims a dispatch once and returns the dedupe key', async () => {
|
test('claims a dispatch once and returns the dedupe key', async () => {
|
||||||
const repository = new ReminderDispatchRepositoryStub()
|
const repository = new ReminderDispatchRepositoryStub()
|
||||||
|
repository.nextResult = {
|
||||||
|
dedupeKey: '2026-03:rent-due',
|
||||||
|
claimed: true
|
||||||
|
}
|
||||||
const service = createReminderJobService(repository)
|
const service = createReminderJobService(repository)
|
||||||
|
|
||||||
const result = await service.handleJob({
|
const result = await service.handleJob({
|
||||||
@@ -53,6 +57,7 @@ describe('createReminderJobService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(result.status).toBe('claimed')
|
expect(result.status).toBe('claimed')
|
||||||
|
expect(result.dedupeKey).toBe('2026-03:rent-due')
|
||||||
expect(repository.lastClaim).toMatchObject({
|
expect(repository.lastClaim).toMatchObject({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
period: '2026-03',
|
period: '2026-03',
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ function computePayloadHash(payload: object): string {
|
|||||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
|
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 {
|
function createReminderMessage(reminderType: ReminderType, period: string): string {
|
||||||
switch (reminderType) {
|
switch (reminderType) {
|
||||||
case 'utilities':
|
case 'utilities':
|
||||||
@@ -56,7 +60,7 @@ export function createReminderJobService(
|
|||||||
if (input.dryRun === true) {
|
if (input.dryRun === true) {
|
||||||
return {
|
return {
|
||||||
status: 'dry-run',
|
status: 'dry-run',
|
||||||
dedupeKey: `${period}:${input.reminderType}`,
|
dedupeKey: buildReminderDedupeKey(period, input.reminderType),
|
||||||
payloadHash,
|
payloadHash,
|
||||||
reminderType: input.reminderType,
|
reminderType: input.reminderType,
|
||||||
period,
|
period,
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL {
|
|||||||
async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> {
|
async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise<any> {
|
||||||
const response = await fetch(url, init)
|
const response = await fetch(url, init)
|
||||||
const text = await response.text()
|
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) {
|
if (response.status !== expectedStatus) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ function requireEnv(name: string): string {
|
|||||||
return value
|
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,
|
botToken: string,
|
||||||
method: string,
|
method: string,
|
||||||
body?: URLSearchParams
|
body?: URLSearchParams
|
||||||
): Promise<any> {
|
): Promise<T> {
|
||||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
||||||
method: body ? 'POST' : 'GET',
|
method: body ? 'POST' : 'GET',
|
||||||
body
|
body
|
||||||
@@ -27,11 +37,11 @@ async function telegramRequest(
|
|||||||
throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`)
|
throw new Error(`Telegram ${method} failed: ${JSON.stringify(payload)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload.result
|
return payload.result as T
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
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')
|
const botToken = requireEnv('TELEGRAM_BOT_TOKEN')
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
|
|||||||
Reference in New Issue
Block a user