mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:14:02 +00:00
feat(infra): add reminder scheduler jobs
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
75
apps/bot/src/scheduler-auth.test.ts
Normal file
75
apps/bot/src/scheduler-auth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
81
apps/bot/src/scheduler-auth.ts
Normal file
81
apps/bot/src/scheduler-auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user