feat(infra): add reminder scheduler jobs

This commit is contained in:
2026-03-08 22:23:19 +04:00
parent 1b08da4591
commit fd0680c8ef
18 changed files with 474 additions and 59 deletions

View File

@@ -10,6 +10,7 @@ export interface BotRuntimeConfig {
purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean
schedulerSharedSecret?: string
schedulerOidcAllowedEmails: readonly string[]
reminderJobsEnabled: boolean
openaiApiKey?: string
parserModel: string
@@ -54,12 +55,26 @@ function parseOptionalValue(value: string | undefined): string | undefined {
return trimmed && trimmed.length > 0 ? trimmed : undefined
}
function parseOptionalCsv(value: string | undefined): readonly string[] {
const trimmed = value?.trim()
if (!trimmed) {
return []
}
return trimmed
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
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 schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const purchaseTopicIngestionEnabled =
databaseUrl !== undefined &&
@@ -68,8 +83,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled =
databaseUrl !== undefined && householdId !== undefined && schedulerSharedSecret !== undefined
databaseUrl !== undefined &&
householdId !== undefined &&
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
const runtime: BotRuntimeConfig = {
port: parsePort(env.PORT),
@@ -78,6 +96,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled,
financeCommandsEnabled,
schedulerOidcAllowedEmails,
reminderJobsEnabled,
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
}

View File

@@ -15,6 +15,7 @@ import {
registerPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
import { createBotWebhookServer } from './server'
const runtime = getBotRuntimeConfig()
@@ -78,7 +79,7 @@ const reminderJobs = runtime.reminderJobsEnabled
if (!runtime.reminderJobsEnabled) {
console.warn(
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and SCHEDULER_SHARED_SECRET to enable.'
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
)
}
@@ -89,10 +90,20 @@ const server = createBotWebhookServer({
scheduler:
reminderJobs && runtime.schedulerSharedSecret
? {
sharedSecret: runtime.schedulerSharedSecret,
authorize: createSchedulerRequestAuthorizer({
sharedSecret: runtime.schedulerSharedSecret,
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: undefined
: reminderJobs
? {
authorize: createSchedulerRequestAuthorizer({
oidcAllowedEmails: runtime.schedulerOidcAllowedEmails
}).authorize,
handler: reminderJobs.handle
}
: undefined
})
if (import.meta.main) {

View File

@@ -62,6 +62,7 @@ export function createReminderJobsHandler(options: {
try {
const body = await readBody(request)
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
const dryRun = options.forceDryRun === true || body.dryRun === true
const result = await options.reminderService.handleJob({
@@ -75,7 +76,7 @@ export function createReminderJobsHandler(options: {
event: 'scheduler.reminder.dispatch',
reminderType,
period,
jobId: body.jobId ?? null,
jobId: body.jobId ?? schedulerJobName ?? null,
dedupeKey: result.dedupeKey,
outcome: result.status,
dryRun
@@ -85,7 +86,7 @@ export function createReminderJobsHandler(options: {
return json({
ok: true,
jobId: body.jobId ?? null,
jobId: body.jobId ?? schedulerJobName ?? null,
reminderType,
period,
dedupeKey: result.dedupeKey,

View File

@@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test'
import { createSchedulerRequestAuthorizer, type IdTokenVerifier } from './scheduler-auth'
describe('createSchedulerRequestAuthorizer', () => {
test('accepts matching shared secret header', async () => {
const authorizer = createSchedulerRequestAuthorizer({
sharedSecret: 'secret'
})
const authorized = await authorizer.authorize(
new Request('http://localhost/jobs/reminder/utilities', {
headers: {
'x-household-scheduler-secret': 'secret'
}
})
)
expect(authorized).toBe(true)
})
test('accepts verified oidc token from an allowed service account', async () => {
const verifier: IdTokenVerifier = {
verifyIdToken: async () => ({
getPayload: () => ({
email: 'dev-scheduler@example.iam.gserviceaccount.com',
email_verified: true
})
})
}
const authorizer = createSchedulerRequestAuthorizer({
oidcAudience: 'https://household-dev-bot-api.run.app',
oidcAllowedEmails: ['dev-scheduler@example.iam.gserviceaccount.com'],
verifier
})
const authorized = await authorizer.authorize(
new Request('http://localhost/jobs/reminder/utilities', {
headers: {
authorization: 'Bearer signed-id-token'
}
})
)
expect(authorized).toBe(true)
})
test('rejects oidc token from an unexpected service account', async () => {
const verifier: IdTokenVerifier = {
verifyIdToken: async () => ({
getPayload: () => ({
email: 'someone-else@example.iam.gserviceaccount.com',
email_verified: true
})
})
}
const authorizer = createSchedulerRequestAuthorizer({
oidcAudience: 'https://household-dev-bot-api.run.app',
oidcAllowedEmails: ['dev-scheduler@example.iam.gserviceaccount.com'],
verifier
})
const authorized = await authorizer.authorize(
new Request('http://localhost/jobs/reminder/utilities', {
headers: {
authorization: 'Bearer signed-id-token'
}
})
)
expect(authorized).toBe(false)
})
})

View File

@@ -0,0 +1,81 @@
import { OAuth2Client } from 'google-auth-library'
interface IdTokenPayload {
email?: string
email_verified?: boolean
}
interface IdTokenTicket {
getPayload(): IdTokenPayload | undefined
}
export interface IdTokenVerifier {
verifyIdToken(input: { idToken: string; audience: string }): Promise<IdTokenTicket>
}
const DEFAULT_VERIFIER: IdTokenVerifier = new OAuth2Client()
function bearerToken(request: Request): string | null {
const header = request.headers.get('authorization')
if (!header?.startsWith('Bearer ')) {
return null
}
const token = header.slice('Bearer '.length).trim()
return token.length > 0 ? token : null
}
export function createSchedulerRequestAuthorizer(options: {
sharedSecret?: string
oidcAudience?: string
oidcAllowedEmails?: readonly string[]
verifier?: IdTokenVerifier
}): {
authorize: (request: Request) => Promise<boolean>
} {
const sharedSecret = options.sharedSecret?.trim()
const oidcAudience = options.oidcAudience?.trim()
const allowedEmails = new Set(
(options.oidcAllowedEmails ?? []).map((email) => email.trim()).filter(Boolean)
)
const verifier = options.verifier ?? DEFAULT_VERIFIER
return {
authorize: async (request) => {
const customHeader = request.headers.get('x-household-scheduler-secret')
if (sharedSecret && customHeader === sharedSecret) {
return true
}
const token = bearerToken(request)
if (!token) {
return false
}
if (sharedSecret && token === sharedSecret) {
return true
}
if (!oidcAudience || allowedEmails.size === 0) {
if (allowedEmails.size === 0) {
return false
}
}
try {
const audience = oidcAudience ?? new URL(request.url).origin
const ticket = await verifier.verifyIdToken({
idToken: token,
audience
})
const payload = ticket.getPayload()
const email = payload?.email?.trim()
return payload?.email_verified === true && email !== undefined && allowedEmails.has(email)
} catch {
return false
}
}
}
}

View File

@@ -8,7 +8,8 @@ describe('createBotWebhookServer', () => {
webhookSecret: 'secret-token',
webhookHandler: async () => new Response('ok', { status: 200 }),
scheduler: {
sharedSecret: 'scheduler-secret',
authorize: async (request) =>
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
handler: async (_request, reminderType) =>
new Response(JSON.stringify({ ok: true, reminderType }), {
status: 200,

View File

@@ -5,7 +5,7 @@ export interface BotWebhookServerOptions {
scheduler?:
| {
pathPrefix?: string
sharedSecret: string
authorize: (request: Request) => Promise<boolean>
handler: (request: Request, reminderType: string) => Promise<Response>
}
| undefined
@@ -26,13 +26,6 @@ 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>
} {
@@ -57,7 +50,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return new Response('Method Not Allowed', { status: 405 })
}
if (!isSchedulerAuthorized(request, options.scheduler!.sharedSecret)) {
if (!(await options.scheduler!.authorize(request))) {
return new Response('Unauthorized', { status: 401 })
}