From c8b17136be9517671c59d8ac1bab3a771c6ffc96 Mon Sep 17 00:00:00 2001 From: whekin Date: Mon, 9 Mar 2026 00:30:31 +0400 Subject: [PATCH] fix(review): harden miniapp auth and finance flows --- apps/bot/src/miniapp-auth.test.ts | 93 ++++++++++++++----- apps/bot/src/miniapp-auth.ts | 36 ++++++- apps/bot/src/miniapp-dashboard.test.ts | 56 +++++++---- apps/bot/src/miniapp-dashboard.ts | 4 +- apps/bot/src/reminder-jobs.ts | 13 +-- apps/bot/src/scheduler-auth.ts | 6 +- apps/bot/src/telegram-miniapp-auth.test.ts | 42 ++++----- apps/bot/src/telegram-miniapp-auth.ts | 6 +- apps/bot/src/telegram-miniapp-test-helpers.ts | 19 ++++ apps/miniapp/src/App.tsx | 6 +- apps/miniapp/src/index.css | 17 ++++ apps/miniapp/src/miniapp-api.ts | 2 +- apps/miniapp/src/telegram-webapp.ts | 4 + docs/runbooks/iac-terraform.md | 6 ++ docs/specs/HOUSEBOT-040-miniapp-shell.md | 2 +- .../src/anonymous-feedback-repository.ts | 48 ++++------ .../adapters-db/src/finance-repository.ts | 76 ++++++++------- .../src/finance-command-service.ts | 9 +- .../src/reminder-job-service.test.ts | 5 + .../application/src/reminder-job-service.ts | 6 +- scripts/ops/deploy-smoke.ts | 10 +- scripts/ops/telegram-webhook.ts | 18 +++- 22 files changed, 327 insertions(+), 157 deletions(-) create mode 100644 apps/bot/src/telegram-miniapp-test-helpers.ts diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts index e812255..d263453 100644 --- a/apps/bot/src/miniapp-auth.test.ts +++ b/apps/bot/src/miniapp-auth.test.ts @@ -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> @@ -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 + } + } + }) }) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 3316a16..8d99693 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -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 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) } } } diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 180c9be..ec5e8cf 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -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> @@ -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' + }) + }) }) diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index f8f8dc4..bbcf8cb 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -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) } } } diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 19618e3..a05143e 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -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 { 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: { diff --git a/apps/bot/src/scheduler-auth.ts b/apps/bot/src/scheduler-auth.ts index 981a1c3..e579cd6 100644 --- a/apps/bot/src/scheduler-auth.ts +++ b/apps/bot/src/scheduler-auth.ts @@ -57,10 +57,8 @@ export function createSchedulerRequestAuthorizer(options: { return true } - if (!oidcAudience || allowedEmails.size === 0) { - if (allowedEmails.size === 0) { - return false - } + if (allowedEmails.size === 0) { + return false } try { diff --git a/apps/bot/src/telegram-miniapp-auth.test.ts b/apps/bot/src/telegram-miniapp-auth.test.ts index 45e53f1..c5c3fb6 100644 --- a/apps/bot/src/telegram-miniapp-auth.test.ts +++ b/apps/bot/src/telegram-miniapp-auth.test.ts @@ -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' }) diff --git a/apps/bot/src/telegram-miniapp-auth.ts b/apps/bot/src/telegram-miniapp-auth.ts index fe543b9..6582f82 100644 --- a/apps/bot/src/telegram-miniapp-auth.ts +++ b/apps/bot/src/telegram-miniapp-auth.ts @@ -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 } diff --git a/apps/bot/src/telegram-miniapp-test-helpers.ts b/apps/bot/src/telegram-miniapp-test-helpers.ts new file mode 100644 index 0000000..f622ac0 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-test-helpers.ts @@ -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() +} diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 1f6eb22..b9acbbe 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -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 { diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index f65eb87..43ad106 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -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; diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index c040b2f..89fa262 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -77,7 +77,7 @@ export async function fetchMiniAppSession(initData: string): Promise`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`count(*) filter (where ${schema.anonymousMessages.createdAt} >= ${acceptedSince})`, + lastAcceptedAt: sql`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 } diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index 5d18644..05fae4f 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -299,48 +299,54 @@ export function createDbFinanceRepository( }, async replaceSettlementSnapshot(snapshot) { - const upserted = await db - .insert(schema.settlements) - .values({ - householdId, - cycleId: snapshot.cycleId, - inputHash: snapshot.inputHash, - totalDueMinor: snapshot.totalDueMinor, - currency: snapshot.currency, - metadata: snapshot.metadata - }) - .onConflictDoUpdate({ - target: [schema.settlements.cycleId], - set: { + await db.transaction(async (tx) => { + const upserted = await tx + .insert(schema.settlements) + .values({ + householdId, + cycleId: snapshot.cycleId, inputHash: snapshot.inputHash, totalDueMinor: snapshot.totalDueMinor, currency: snapshot.currency, - computedAt: new Date(), metadata: snapshot.metadata - } - }) - .returning({ id: schema.settlements.id }) + }) + .onConflictDoUpdate({ + 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 - if (!settlementId) { - throw new Error('Failed to persist settlement snapshot') - } + const settlementId = upserted[0]?.id + if (!settlementId) { + throw new Error('Failed to persist settlement snapshot') + } - await db - .delete(schema.settlementLines) - .where(eq(schema.settlementLines.settlementId, settlementId)) + await tx + .delete(schema.settlementLines) + .where(eq(schema.settlementLines.settlementId, settlementId)) - await db.insert(schema.settlementLines).values( - snapshot.lines.map((line) => ({ - settlementId, - memberId: line.memberId, - rentShareMinor: line.rentShareMinor, - utilityShareMinor: line.utilityShareMinor, - purchaseOffsetMinor: line.purchaseOffsetMinor, - netDueMinor: line.netDueMinor, - explanations: line.explanations - })) - ) + if (snapshot.lines.length === 0) { + return + } + + await tx.insert(schema.settlementLines).values( + snapshot.lines.map((line) => ({ + settlementId, + memberId: line.memberId, + rentShareMinor: line.rentShareMinor, + utilityShareMinor: line.utilityShareMinor, + purchaseOffsetMinor: line.purchaseOffsetMinor, + netDueMinor: line.netDueMinor, + explanations: line.explanations + })) + ) + }) } } diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 8edc41b..e59f9a9 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -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) { diff --git a/packages/application/src/reminder-job-service.test.ts b/packages/application/src/reminder-job-service.test.ts index 2b1b620..e3d05d5 100644 --- a/packages/application/src/reminder-job-service.test.ts +++ b/packages/application/src/reminder-job-service.test.ts @@ -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', diff --git a/packages/application/src/reminder-job-service.ts b/packages/application/src/reminder-job-service.ts index dcdd121..b86b5f4 100644 --- a/packages/application/src/reminder-job-service.ts +++ b/packages/application/src/reminder-job-service.ts @@ -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, diff --git a/scripts/ops/deploy-smoke.ts b/scripts/ops/deploy-smoke.ts index 07edaff..01ca854 100644 --- a/scripts/ops/deploy-smoke.ts +++ b/scripts/ops/deploy-smoke.ts @@ -15,7 +15,15 @@ function toUrl(base: string, path: string): URL { async function expectJson(url: URL, init: RequestInit, expectedStatus: number): Promise { 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( diff --git a/scripts/ops/telegram-webhook.ts b/scripts/ops/telegram-webhook.ts index 26b40ab..09015f8 100644 --- a/scripts/ops/telegram-webhook.ts +++ b/scripts/ops/telegram-webhook.ts @@ -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( botToken: string, method: string, body?: URLSearchParams -): Promise { +): Promise { 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 { - const command = (process.argv[2] ?? 'info') as WebhookCommand + const command = parseCommand(process.argv[2]) const botToken = requireEnv('TELEGRAM_BOT_TOKEN') switch (command) {