feat(bot): add secure reminder job runtime

This commit is contained in:
2026-03-08 22:15:01 +04:00
parent f6d1f34acf
commit 6c0dbfc48e
14 changed files with 670 additions and 4 deletions

View File

@@ -9,6 +9,8 @@ export interface BotRuntimeConfig {
telegramPurchaseTopicId?: number
purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean
schedulerSharedSecret?: string
reminderJobsEnabled: boolean
openaiApiKey?: string
parserModel: string
}
@@ -57,6 +59,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
const purchaseTopicIngestionEnabled =
databaseUrl !== undefined &&
@@ -65,6 +68,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
const reminderJobsEnabled =
databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined
const runtime: BotRuntimeConfig = {
port: parsePort(env.PORT),
@@ -73,6 +78,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled,
financeCommandsEnabled,
reminderJobsEnabled,
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
}
@@ -88,6 +94,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
if (telegramPurchaseTopicId !== undefined) {
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
}
if (schedulerSharedSecret !== undefined) {
runtime.schedulerSharedSecret = schedulerSharedSecret
}
const openaiApiKey = parseOptionalValue(env.OPENAI_API_KEY)
if (openaiApiKey !== undefined) {
runtime.openaiApiKey = openaiApiKey

View File

@@ -1,7 +1,10 @@
import { webhookCallback } from 'grammy'
import { createFinanceCommandService } from '@household/application'
import { createDbFinanceRepository } from '@household/adapters-db'
import { createFinanceCommandService, createReminderJobService } from '@household/application'
import {
createDbFinanceRepository,
createDbReminderDispatchRepository
} from '@household/adapters-db'
import { createFinanceCommandsService } from './finance-commands'
import { createTelegramBot } from './bot'
@@ -11,6 +14,7 @@ import {
createPurchaseMessageRepository,
registerPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { createBotWebhookServer } from './server'
const runtime = getBotRuntimeConfig()
@@ -58,10 +62,37 @@ if (runtime.financeCommandsEnabled) {
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
}
const reminderJobs = runtime.reminderJobsEnabled
? (() => {
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)
const reminderService = createReminderJobService(reminderRepositoryClient.repository)
shutdownTasks.push(reminderRepositoryClient.close)
return createReminderJobsHandler({
householdId: runtime.householdId!,
reminderService
})
})()
: null
if (!runtime.reminderJobsEnabled) {
console.warn(
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and SCHEDULER_SHARED_SECRET to enable.'
)
}
const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret,
webhookHandler
webhookHandler,
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {
sharedSecret: runtime.schedulerSharedSecret,
handler: reminderJobs.handle
}
: undefined
})
if (import.meta.main) {

View File

@@ -0,0 +1,110 @@
import { describe, expect, mock, test } from 'bun:test'
import type { ReminderJobResult, ReminderJobService } from '@household/application'
import { createReminderJobsHandler } from './reminder-jobs'
describe('createReminderJobsHandler', () => {
test('returns job outcome with dedupe metadata', async () => {
const claimedResult: ReminderJobResult = {
status: 'claimed',
dedupeKey: '2026-03:utilities',
payloadHash: 'hash',
reminderType: 'utilities',
period: '2026-03',
messageText: 'Utilities reminder for 2026-03'
}
const reminderService: ReminderJobService = {
handleJob: mock(async () => claimedResult)
}
const handler = createReminderJobsHandler({
householdId: 'household-1',
reminderService
})
const response = await handler.handle(
new Request('http://localhost/jobs/reminder/utilities', {
method: 'POST',
body: JSON.stringify({
period: '2026-03',
jobId: 'job-1'
})
}),
'utilities'
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
jobId: 'job-1',
reminderType: 'utilities',
period: '2026-03',
dedupeKey: '2026-03:utilities',
outcome: 'claimed',
dryRun: false,
messageText: 'Utilities reminder for 2026-03'
})
})
test('supports forced dry-run mode', async () => {
const dryRunResult: ReminderJobResult = {
status: 'dry-run',
dedupeKey: '2026-03:rent-warning',
payloadHash: 'hash',
reminderType: 'rent-warning',
period: '2026-03',
messageText: 'Rent reminder for 2026-03: payment is coming up soon.'
}
const reminderService: ReminderJobService = {
handleJob: mock(async () => dryRunResult)
}
const handler = createReminderJobsHandler({
householdId: 'household-1',
reminderService,
forceDryRun: true
})
const response = await handler.handle(
new Request('http://localhost/jobs/reminder/rent-warning', {
method: 'POST',
body: JSON.stringify({ period: '2026-03', jobId: 'job-2' })
}),
'rent-warning'
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
outcome: 'dry-run',
dryRun: true
})
})
test('rejects unsupported reminder type', async () => {
const handler = createReminderJobsHandler({
householdId: 'household-1',
reminderService: {
handleJob: mock(async () => {
throw new Error('should not be called')
})
}
})
const response = await handler.handle(
new Request('http://localhost/jobs/reminder/unknown', {
method: 'POST',
body: JSON.stringify({ period: '2026-03' })
}),
'unknown'
)
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
ok: false,
error: 'Invalid reminder type'
})
})
})

View File

@@ -0,0 +1,111 @@
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]
interface ReminderJobRequestBody {
period?: string
jobId?: string
dryRun?: boolean
}
function json(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
}
function parseReminderType(raw: string): ReminderType | null {
if ((REMINDER_TYPES as readonly string[]).includes(raw)) {
return raw as ReminderType
}
return null
}
function currentPeriod(): string {
const now = new Date()
const year = now.getUTCFullYear()
const month = `${now.getUTCMonth() + 1}`.padStart(2, '0')
return `${year}-${month}`
}
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
const text = await request.text()
if (text.trim().length === 0) {
return {}
}
const parsed = JSON.parse(text) as ReminderJobRequestBody
return parsed
}
export function createReminderJobsHandler(options: {
householdId: string
reminderService: ReminderJobService
forceDryRun?: boolean
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
} {
return {
handle: async (request, rawReminderType) => {
const reminderType = parseReminderType(rawReminderType)
if (!reminderType) {
return json({ ok: false, error: 'Invalid reminder type' }, 400)
}
try {
const body = await readBody(request)
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
const dryRun = options.forceDryRun === true || body.dryRun === true
const result = await options.reminderService.handleJob({
householdId: options.householdId,
period,
reminderType,
dryRun
})
const logPayload = {
event: 'scheduler.reminder.dispatch',
reminderType,
period,
jobId: body.jobId ?? null,
dedupeKey: result.dedupeKey,
outcome: result.status,
dryRun
}
console.log(JSON.stringify(logPayload))
return json({
ok: true,
jobId: body.jobId ?? null,
reminderType,
period,
dedupeKey: result.dedupeKey,
outcome: result.status,
dryRun,
messageText: result.messageText
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
console.error(
JSON.stringify({
event: 'scheduler.reminder.dispatch_failed',
reminderType: rawReminderType,
error: message
})
)
return json({ ok: false, error: message }, 400)
}
}
}
}

View File

@@ -6,7 +6,17 @@ describe('createBotWebhookServer', () => {
const server = createBotWebhookServer({
webhookPath: '/webhook/telegram',
webhookSecret: 'secret-token',
webhookHandler: async () => new Response('ok', { status: 200 })
webhookHandler: async () => new Response('ok', { status: 200 }),
scheduler: {
sharedSecret: 'scheduler-secret',
handler: async (_request, reminderType) =>
new Response(JSON.stringify({ ok: true, reminderType }), {
status: 200,
headers: {
'content-type': 'application/json; charset=utf-8'
}
})
}
})
test('returns health payload', async () => {
@@ -59,4 +69,46 @@ describe('createBotWebhookServer', () => {
expect(response.status).toBe(200)
expect(await response.text()).toBe('ok')
})
test('rejects scheduler request with missing secret', async () => {
const response = await server.fetch(
new Request('http://localhost/jobs/reminder/utilities', {
method: 'POST',
body: JSON.stringify({ period: '2026-03' })
})
)
expect(response.status).toBe(401)
})
test('rejects non-post method for scheduler endpoint', async () => {
const response = await server.fetch(
new Request('http://localhost/jobs/reminder/utilities', {
method: 'GET',
headers: {
'x-household-scheduler-secret': 'scheduler-secret'
}
})
)
expect(response.status).toBe(405)
})
test('accepts authorized scheduler request', async () => {
const response = await server.fetch(
new Request('http://localhost/jobs/reminder/rent-due', {
method: 'POST',
headers: {
'x-household-scheduler-secret': 'scheduler-secret'
},
body: JSON.stringify({ period: '2026-03' })
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
reminderType: 'rent-due'
})
})
})

View File

@@ -2,6 +2,13 @@ export interface BotWebhookServerOptions {
webhookPath: string
webhookSecret: string
webhookHandler: (request: Request) => Promise<Response> | Response
scheduler?:
| {
pathPrefix?: string
sharedSecret: string
handler: (request: Request, reminderType: string) => Promise<Response>
}
| undefined
}
function json(body: object, status = 200): Response {
@@ -19,12 +26,22 @@ function isAuthorized(request: Request, expectedSecret: string): boolean {
return secretHeader === expectedSecret
}
function isSchedulerAuthorized(request: Request, expectedSecret: string): boolean {
const customHeader = request.headers.get('x-household-scheduler-secret')
const authorizationHeader = request.headers.get('authorization')
return customHeader === expectedSecret || authorizationHeader === `Bearer ${expectedSecret}`
}
export function createBotWebhookServer(options: BotWebhookServerOptions): {
fetch: (request: Request) => Promise<Response>
} {
const normalizedWebhookPath = options.webhookPath.startsWith('/')
? options.webhookPath
: `/${options.webhookPath}`
const schedulerPathPrefix = options.scheduler
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
: null
return {
fetch: async (request: Request) => {
@@ -35,6 +52,19 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
}
if (url.pathname !== normalizedWebhookPath) {
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
if (!isSchedulerAuthorized(request, options.scheduler!.sharedSecret)) {
return new Response('Unauthorized', { status: 401 })
}
const reminderType = url.pathname.slice(`${schedulerPathPrefix}/`.length)
return await options.scheduler!.handler(request, reminderType)
}
return new Response('Not Found', { status: 404 })
}

View File

@@ -0,0 +1,81 @@
# HOUSEBOT-031: Secure Scheduler Endpoint and Idempotent Reminder Dispatch
## Summary
Add authenticated reminder job endpoints to the bot runtime with deterministic deduplication and dry-run support.
## Goals
- Accept reminder job calls through dedicated HTTP endpoints.
- Reject unauthorized or malformed scheduler requests.
- Prevent duplicate reminder dispatch for the same household, period, and reminder type.
- Emit structured outcomes for local validation and future monitoring.
## Non-goals
- Full Cloud Scheduler IaC setup.
- Final Telegram reminder copy or topic routing.
- OIDC verification in v1 of this runtime slice.
## Scope
- In: shared-secret auth, request validation, dry-run mode, dedupe persistence, structured logs.
- Out: live Telegram send integration and scheduler provisioning.
## Interfaces and Contracts
- Endpoint family: `/jobs/reminder/<type>`
- Allowed types:
- `utilities`
- `rent-warning`
- `rent-due`
- Request body:
- `period?: YYYY-MM`
- `jobId?: string`
- `dryRun?: boolean`
- Auth:
- `x-household-scheduler-secret: <secret>` or `Authorization: Bearer <secret>`
## Domain Rules
- Dedupe key format: `<period>:<reminderType>`
- Persistence uniqueness remains household-scoped.
- `dryRun=true` never persists a dispatch claim.
## Data Model Changes
- None. Reuse `processed_bot_messages` as the idempotency ledger for scheduler reminder claims.
## Security and Privacy
- Scheduler routes are unavailable unless `SCHEDULER_SHARED_SECRET` is configured.
- Unauthorized callers receive `401`.
- Request errors return `400` without leaking secrets.
## Observability
- Successful and failed job handling emits structured JSON logs.
- Log payload includes:
- `jobId`
- `dedupeKey`
- `outcome`
- `reminderType`
- `period`
## Edge Cases and Failure Modes
- Empty body defaults period to the current UTC billing month.
- Invalid period format is rejected.
- Replayed jobs return `duplicate` without a second dispatch claim.
## Test Plan
- Unit: reminder job service dry-run and dedupe results.
- Integration-ish: HTTP handler auth, route validation, and response payloads.
## Acceptance Criteria
- [ ] Unauthorized scheduler requests are rejected.
- [ ] Duplicate scheduler calls return a deterministic duplicate outcome.
- [ ] Dry-run mode skips persistence and still returns a structured payload.
- [ ] Logs include `jobId`, `dedupeKey`, and outcome.

View File

@@ -1 +1,2 @@
export { createDbFinanceRepository } from './finance-repository'
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'

View File

@@ -0,0 +1,46 @@
import { createDbClient, schema } from '@household/db'
import type { ReminderDispatchRepository } from '@household/ports'
export function createDbReminderDispatchRepository(databaseUrl: string): {
repository: ReminderDispatchRepository
close: () => Promise<void>
} {
const { db, queryClient } = createDbClient(databaseUrl, {
max: 3,
prepare: false
})
const repository: ReminderDispatchRepository = {
async claimReminderDispatch(input) {
const dedupeKey = `${input.period}:${input.reminderType}`
const rows = await db
.insert(schema.processedBotMessages)
.values({
householdId: input.householdId,
source: 'scheduler-reminder',
sourceMessageKey: dedupeKey,
payloadHash: input.payloadHash
})
.onConflictDoNothing({
target: [
schema.processedBotMessages.householdId,
schema.processedBotMessages.source,
schema.processedBotMessages.sourceMessageKey
]
})
.returning({ id: schema.processedBotMessages.id })
return {
dedupeKey,
claimed: rows.length > 0
}
}
}
return {
repository,
close: async () => {
await queryClient.end({ timeout: 5 })
}
}
}

View File

@@ -1,5 +1,10 @@
export { calculateMonthlySettlement } from './settlement-engine'
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
export {
createReminderJobService,
type ReminderJobResult,
type ReminderJobService
} from './reminder-job-service'
export {
parsePurchaseMessage,
type ParsedPurchaseResult,

View File

@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test'
import type {
ClaimReminderDispatchInput,
ClaimReminderDispatchResult,
ReminderDispatchRepository
} from '@household/ports'
import { createReminderJobService } from './reminder-job-service'
class ReminderDispatchRepositoryStub implements ReminderDispatchRepository {
nextResult: ClaimReminderDispatchResult = {
dedupeKey: '2026-03:utilities',
claimed: true
}
lastClaim: ClaimReminderDispatchInput | null = null
async claimReminderDispatch(
input: ClaimReminderDispatchInput
): Promise<ClaimReminderDispatchResult> {
this.lastClaim = input
return this.nextResult
}
}
describe('createReminderJobService', () => {
test('returns dry-run result without touching the repository', async () => {
const repository = new ReminderDispatchRepositoryStub()
const service = createReminderJobService(repository)
const result = await service.handleJob({
householdId: 'household-1',
period: '2026-03',
reminderType: 'utilities',
dryRun: true
})
expect(result.status).toBe('dry-run')
expect(result.dedupeKey).toBe('2026-03:utilities')
expect(result.messageText).toBe('Utilities reminder for 2026-03')
expect(repository.lastClaim).toBeNull()
})
test('claims a dispatch once and returns the dedupe key', async () => {
const repository = new ReminderDispatchRepositoryStub()
const service = createReminderJobService(repository)
const result = await service.handleJob({
householdId: 'household-1',
period: '2026-03',
reminderType: 'rent-due'
})
expect(result.status).toBe('claimed')
expect(repository.lastClaim).toMatchObject({
householdId: 'household-1',
period: '2026-03',
reminderType: 'rent-due'
})
})
test('returns duplicate when the repository rejects a replay', async () => {
const repository = new ReminderDispatchRepositoryStub()
repository.nextResult = {
dedupeKey: '2026-03:rent-warning',
claimed: false
}
const service = createReminderJobService(repository)
const result = await service.handleJob({
householdId: 'household-1',
period: '2026-03',
reminderType: 'rent-warning'
})
expect(result.status).toBe('duplicate')
expect(result.dedupeKey).toBe('2026-03:rent-warning')
})
})

View File

@@ -0,0 +1,84 @@
import { createHash } from 'node:crypto'
import { BillingPeriod } from '@household/domain'
import type {
ClaimReminderDispatchResult,
ReminderDispatchRepository,
ReminderType
} from '@household/ports'
function computePayloadHash(payload: object): string {
return createHash('sha256').update(JSON.stringify(payload)).digest('hex')
}
function createReminderMessage(reminderType: ReminderType, period: string): string {
switch (reminderType) {
case 'utilities':
return `Utilities reminder for ${period}`
case 'rent-warning':
return `Rent reminder for ${period}: payment is coming up soon.`
case 'rent-due':
return `Rent due reminder for ${period}: please settle payment today.`
}
}
export interface ReminderJobResult {
status: 'dry-run' | 'claimed' | 'duplicate'
dedupeKey: string
payloadHash: string
reminderType: ReminderType
period: string
messageText: string
}
export interface ReminderJobService {
handleJob(input: {
householdId: string
period: string
reminderType: ReminderType
dryRun?: boolean
}): Promise<ReminderJobResult>
}
export function createReminderJobService(
repository: ReminderDispatchRepository
): ReminderJobService {
return {
async handleJob(input) {
const period = BillingPeriod.fromString(input.period).toString()
const payloadHash = computePayloadHash({
householdId: input.householdId,
period,
reminderType: input.reminderType
})
const messageText = createReminderMessage(input.reminderType, period)
if (input.dryRun === true) {
return {
status: 'dry-run',
dedupeKey: `${period}:${input.reminderType}`,
payloadHash,
reminderType: input.reminderType,
period,
messageText
}
}
const result: ClaimReminderDispatchResult = await repository.claimReminderDispatch({
householdId: input.householdId,
period,
reminderType: input.reminderType,
payloadHash
})
return {
status: result.claimed ? 'claimed' : 'duplicate',
dedupeKey: result.dedupeKey,
payloadHash,
reminderType: input.reminderType,
period,
messageText
}
}
}
}

View File

@@ -1,3 +1,10 @@
export {
REMINDER_TYPES,
type ClaimReminderDispatchInput,
type ClaimReminderDispatchResult,
type ReminderDispatchRepository,
type ReminderType
} from './reminders'
export type {
FinanceCycleRecord,
FinanceMemberRecord,

View File

@@ -0,0 +1,19 @@
export const REMINDER_TYPES = ['utilities', 'rent-warning', 'rent-due'] as const
export type ReminderType = (typeof REMINDER_TYPES)[number]
export interface ClaimReminderDispatchInput {
householdId: string
period: string
reminderType: ReminderType
payloadHash: string
}
export interface ClaimReminderDispatchResult {
dedupeKey: string
claimed: boolean
}
export interface ReminderDispatchRepository {
claimReminderDispatch(input: ClaimReminderDispatchInput): Promise<ClaimReminderDispatchResult>
}