diff --git a/.env.example b/.env.example index 8a3b5e8..233988b 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,12 @@ LOG_LEVEL=info PORT=3000 # Database +# Owner connection for migrations, seed, and schema checks only DATABASE_URL=postgres://postgres:postgres@127.0.0.1:54322/postgres +# Runtime connection used by mini app and authenticated user-triggered API flows +APP_DATABASE_URL=postgres://housebot_app:housebot_app@127.0.0.1:54322/postgres +# Runtime connection used by bot ingestion, reminders, schedulers, and other worker flows +WORKER_DATABASE_URL=postgres://housebot_worker:housebot_worker@127.0.0.1:54322/postgres DB_SCHEMA=public # Telegram diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index dedd885..32de31e 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -4,6 +4,8 @@ export interface BotRuntimeConfig { telegramBotToken: string telegramWebhookSecret: string telegramWebhookPath: string + appDatabaseUrl?: string + workerDatabaseUrl?: string databaseUrl?: string purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean @@ -101,20 +103,22 @@ function parsePositiveInteger(raw: string | undefined, fallback: number, key: st export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig { const databaseUrl = parseOptionalValue(env.DATABASE_URL) + const appDatabaseUrl = parseOptionalValue(env.APP_DATABASE_URL) + const workerDatabaseUrl = parseOptionalValue(env.WORKER_DATABASE_URL) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) const miniAppUrl = parseOptionalValue(env.MINI_APP_URL) - const purchaseTopicIngestionEnabled = databaseUrl !== undefined - - const financeCommandsEnabled = databaseUrl !== undefined - const anonymousFeedbackEnabled = databaseUrl !== undefined - const assistantEnabled = databaseUrl !== undefined - const miniAppAuthEnabled = databaseUrl !== undefined + const purchaseTopicIngestionEnabled = workerDatabaseUrl !== undefined + const financeCommandsEnabled = workerDatabaseUrl !== undefined + const anonymousFeedbackEnabled = workerDatabaseUrl !== undefined + const assistantEnabled = workerDatabaseUrl !== undefined + const miniAppAuthEnabled = appDatabaseUrl !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = - databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) + workerDatabaseUrl !== undefined && + (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig) const runtime: BotRuntimeConfig = { port: parsePort(env.PORT), @@ -173,6 +177,12 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu if (databaseUrl !== undefined) { runtime.databaseUrl = databaseUrl } + if (appDatabaseUrl !== undefined) { + runtime.appDatabaseUrl = appDatabaseUrl + } + if (workerDatabaseUrl !== undefined) { + runtime.workerDatabaseUrl = workerDatabaseUrl + } if (schedulerSharedSecret !== undefined) { runtime.schedulerSharedSecret = schedulerSharedSecret } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index fcaa8b6..8a146f9 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -47,7 +47,11 @@ import { createReminderJobsHandler } from './reminder-jobs' import { registerReminderTopicUtilities } from './reminder-topic-utilities' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' -import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth' +import { + createMiniAppAuthHandler, + createMiniAppJoinHandler, + type MiniAppAuthorizedSession +} from './miniapp-auth' import { createMiniAppDashboardHandler } from './miniapp-dashboard' import { createMiniAppApproveMemberHandler, @@ -90,13 +94,13 @@ configureLogger({ const logger = getLogger('runtime') const shutdownTasks: Array<() => Promise> = [] -const householdConfigurationRepositoryClient = runtime.databaseUrl - ? createDbHouseholdConfigurationRepository(runtime.databaseUrl) +const workerHouseholdConfigurationRepositoryClient = runtime.workerDatabaseUrl + ? createDbHouseholdConfigurationRepository(runtime.workerDatabaseUrl) : null const bot = createTelegramBot( runtime.telegramBotToken, getLogger('telegram'), - householdConfigurationRepositoryClient?.repository + workerHouseholdConfigurationRepositoryClient?.repository ) bot.botInfo = await bot.api.getMe() const webhookHandler = webhookCallback(bot, 'std/http', { @@ -111,29 +115,23 @@ const paymentConfirmationServices = new Map< const exchangeRateProvider = createNbgExchangeRateProvider({ logger: getLogger('fx') }) -const householdOnboardingService = householdConfigurationRepositoryClient +const householdOnboardingService = workerHouseholdConfigurationRepositoryClient ? createHouseholdOnboardingService({ - repository: householdConfigurationRepositoryClient.repository + repository: workerHouseholdConfigurationRepositoryClient.repository }) : null -const miniAppAdminService = householdConfigurationRepositoryClient - ? createMiniAppAdminService(householdConfigurationRepositoryClient.repository) - : null -const localePreferenceService = householdConfigurationRepositoryClient - ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) - : null -const telegramPendingActionRepositoryClient = runtime.databaseUrl - ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) +const telegramPendingActionRepositoryClient = runtime.workerDatabaseUrl + ? createDbTelegramPendingActionRepository(runtime.workerDatabaseUrl!) : null const processedBotMessageRepositoryClient = - runtime.databaseUrl && runtime.assistantEnabled - ? createDbProcessedBotMessageRepository(runtime.databaseUrl!) + runtime.workerDatabaseUrl && runtime.assistantEnabled + ? createDbProcessedBotMessageRepository(runtime.workerDatabaseUrl!) : null -const purchaseRepositoryClient = runtime.databaseUrl - ? createPurchaseMessageRepository(runtime.databaseUrl!) +const purchaseRepositoryClient = runtime.workerDatabaseUrl + ? createPurchaseMessageRepository(runtime.workerDatabaseUrl!) : null -const topicMessageHistoryRepositoryClient = runtime.databaseUrl - ? createDbTopicMessageHistoryRepository(runtime.databaseUrl!) +const topicMessageHistoryRepositoryClient = runtime.workerDatabaseUrl + ? createDbTopicMessageHistoryRepository(runtime.workerDatabaseUrl!) : null const purchaseInterpreter = createOpenAiPurchaseInterpreter( runtime.openaiApiKey, @@ -169,6 +167,167 @@ const anonymousFeedbackServices = new Map< string, ReturnType >() +const appHouseholdConfigurationRepositoryClients = new Map< + string, + ReturnType +>() +const appOnboardingServices = new Map>() +const appFinanceRepositoryClients = new Map>() +const appFinanceServices = new Map>() +const appMiniAppAdminServices = new Map>() +const appLocalePreferenceServices = new Map< + string, + ReturnType +>() + +function miniAppSessionKey(session: MiniAppAuthorizedSession): string { + return [ + session.telegramUserId, + session.member.householdId, + session.member.id, + session.member.isAdmin ? 'admin' : 'member' + ].join(':') +} + +function appHouseholdConfigurationRepositoryKey(input: { + telegramUserId: string + householdId?: string + memberId?: string + isAdmin?: boolean +}): string { + return [ + input.telegramUserId, + input.householdId ?? 'none', + input.memberId ?? 'none', + input.isAdmin === true ? 'admin' : 'member' + ].join(':') +} + +function appHouseholdConfigurationRepositoryForContext(input: { + telegramUserId: string + householdId?: string + memberId?: string + isAdmin?: boolean +}) { + const key = appHouseholdConfigurationRepositoryKey(input) + const existing = appHouseholdConfigurationRepositoryClients.get(key) + if (existing) { + return existing + } + + const repositoryClient = createDbHouseholdConfigurationRepository(runtime.appDatabaseUrl!, { + sessionContext: { + telegramUserId: input.telegramUserId, + ...(input.householdId + ? { + householdId: input.householdId + } + : {}), + ...(input.memberId + ? { + memberId: input.memberId + } + : {}), + ...(input.isAdmin !== undefined + ? { + isAdmin: input.isAdmin + } + : {}) + } + }) + appHouseholdConfigurationRepositoryClients.set(key, repositoryClient) + shutdownTasks.push(repositoryClient.close) + return repositoryClient +} + +function appOnboardingServiceForTelegramUserId(telegramUserId: string) { + const existing = appOnboardingServices.get(telegramUserId) + if (existing) { + return existing + } + + const service = createHouseholdOnboardingService({ + repository: appHouseholdConfigurationRepositoryForContext({ + telegramUserId + }).repository + }) + appOnboardingServices.set(telegramUserId, service) + return service +} + +function appHouseholdConfigurationRepositoryForSession(session: MiniAppAuthorizedSession) { + return appHouseholdConfigurationRepositoryForContext({ + telegramUserId: session.telegramUserId, + householdId: session.member.householdId, + memberId: session.member.id, + isAdmin: session.member.isAdmin + }) +} + +function appFinanceServiceForSession(session: MiniAppAuthorizedSession) { + const key = miniAppSessionKey(session) + const existing = appFinanceServices.get(key) + if (existing) { + return existing + } + + const repositoryClient = createDbFinanceRepository( + runtime.appDatabaseUrl!, + session.member.householdId, + { + sessionContext: { + telegramUserId: session.telegramUserId, + householdId: session.member.householdId, + memberId: session.member.id, + ...(session.member.isAdmin !== undefined + ? { + isAdmin: session.member.isAdmin + } + : {}) + } + } + ) + appFinanceRepositoryClients.set(key, repositoryClient) + shutdownTasks.push(repositoryClient.close) + + const service = createFinanceCommandService({ + householdId: session.member.householdId, + repository: repositoryClient.repository, + householdConfigurationRepository: + appHouseholdConfigurationRepositoryForSession(session).repository, + exchangeRateProvider + }) + appFinanceServices.set(key, service) + return service +} + +function appMiniAppAdminServiceForSession(session: MiniAppAuthorizedSession) { + const key = miniAppSessionKey(session) + const existing = appMiniAppAdminServices.get(key) + if (existing) { + return existing + } + + const service = createMiniAppAdminService( + appHouseholdConfigurationRepositoryForSession(session).repository + ) + appMiniAppAdminServices.set(key, service) + return service +} + +function appLocalePreferenceServiceForSession(session: MiniAppAuthorizedSession) { + const key = miniAppSessionKey(session) + const existing = appLocalePreferenceServices.get(key) + if (existing) { + return existing + } + + const service = createLocalePreferenceService( + appHouseholdConfigurationRepositoryForSession(session).repository + ) + appLocalePreferenceServices.set(key, service) + return service +} function financeServiceForHousehold(householdId: string) { const existing = financeServices.get(householdId) @@ -180,7 +339,7 @@ function financeServiceForHousehold(householdId: string) { const service = createFinanceCommandService({ householdId, repository: repositoryClient.repository, - householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient!.repository, exchangeRateProvider }) financeServices.set(householdId, service) @@ -193,7 +352,7 @@ function financeRepositoryForHousehold(householdId: string) { return existing } - const repositoryClient = createDbFinanceRepository(runtime.databaseUrl!, householdId) + const repositoryClient = createDbFinanceRepository(runtime.workerDatabaseUrl!, householdId) financeRepositoryClients.set(householdId, repositoryClient) shutdownTasks.push(repositoryClient.close) return repositoryClient @@ -209,7 +368,7 @@ function paymentConfirmationServiceForHousehold(householdId: string) { householdId, financeService: financeServiceForHousehold(householdId), repository: financeRepositoryForHousehold(householdId).repository, - householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient!.repository, exchangeRateProvider }) paymentConfirmationServices.set(householdId, service) @@ -222,7 +381,10 @@ function anonymousFeedbackServiceForHousehold(householdId: string) { return existing } - const repositoryClient = createDbAnonymousFeedbackRepository(runtime.databaseUrl!, householdId) + const repositoryClient = createDbAnonymousFeedbackRepository( + runtime.workerDatabaseUrl!, + householdId + ) anonymousFeedbackRepositoryClients.set(householdId, repositoryClient) shutdownTasks.push(repositoryClient.close) @@ -231,8 +393,8 @@ function anonymousFeedbackServiceForHousehold(householdId: string) { return service } -if (householdConfigurationRepositoryClient) { - shutdownTasks.push(householdConfigurationRepositoryClient.close) +if (workerHouseholdConfigurationRepositoryClient) { + shutdownTasks.push(workerHouseholdConfigurationRepositoryClient.close) } if (telegramPendingActionRepositoryClient) { @@ -251,10 +413,10 @@ if (topicMessageHistoryRepositoryClient) { shutdownTasks.push(topicMessageHistoryRepositoryClient.close) } -if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { +if (purchaseRepositoryClient && workerHouseholdConfigurationRepositoryClient) { registerConfiguredPurchaseTopicIngestion( bot, - householdConfigurationRepositoryClient.repository, + workerHouseholdConfigurationRepositoryClient.repository, purchaseRepositoryClient.repository, { ...(topicProcessor @@ -280,7 +442,7 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPaymentTopicIngestion( bot, - householdConfigurationRepositoryClient.repository, + workerHouseholdConfigurationRepositoryClient.repository, telegramPendingActionRepositoryClient!.repository, financeServiceForHousehold, paymentConfirmationServiceForHousehold, @@ -306,13 +468,13 @@ if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { event: 'runtime.feature_disabled', feature: 'purchase-topic-ingestion' }, - 'Purchase topic ingestion is disabled. Set DATABASE_URL to enable Telegram topic lookups.' + 'Purchase topic ingestion is disabled. Set WORKER_DATABASE_URL to enable Telegram topic lookups.' ) } if (runtime.financeCommandsEnabled) { const financeCommands = createFinanceCommandsService({ - householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient!.repository, financeServiceForHousehold, ...(runtime.miniAppUrl ? { @@ -329,21 +491,21 @@ if (runtime.financeCommandsEnabled) { event: 'runtime.feature_disabled', feature: 'finance-commands' }, - 'Finance commands are disabled. Set DATABASE_URL to enable household lookups.' + 'Finance commands are disabled. Set WORKER_DATABASE_URL to enable household lookups.' ) } -if (householdConfigurationRepositoryClient) { +if (workerHouseholdConfigurationRepositoryClient) { registerHouseholdSetupCommands({ bot, householdSetupService: createHouseholdSetupService( - householdConfigurationRepositoryClient.repository + workerHouseholdConfigurationRepositoryClient.repository ), householdAdminService: createHouseholdAdminService( - householdConfigurationRepositoryClient.repository + workerHouseholdConfigurationRepositoryClient.repository ), householdOnboardingService: householdOnboardingService!, - householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient.repository, ...(telegramPendingActionRepositoryClient ? { promptRepository: telegramPendingActionRepositoryClient.repository @@ -362,20 +524,22 @@ if (householdConfigurationRepositoryClient) { event: 'runtime.feature_disabled', feature: 'household-setup' }, - 'Household setup commands are disabled. Set DATABASE_URL to enable.' + 'Household setup commands are disabled. Set WORKER_DATABASE_URL to enable.' ) } const reminderJobs = runtime.reminderJobsEnabled ? (() => { - const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!) + const reminderRepositoryClient = createDbReminderDispatchRepository( + runtime.workerDatabaseUrl! + ) const reminderService = createReminderJobService(reminderRepositoryClient.repository) shutdownTasks.push(reminderRepositoryClient.close) return createReminderJobsHandler({ listReminderTargets: () => - householdConfigurationRepositoryClient!.repository.listReminderTargets(), + workerHouseholdConfigurationRepositoryClient!.repository.listReminderTargets(), ensureBillingCycle: async ({ householdId, at }) => { await financeServiceForHousehold(householdId).ensureExpectedCycle(at) }, @@ -426,19 +590,19 @@ if (!runtime.reminderJobsEnabled) { event: 'runtime.feature_disabled', feature: 'reminder-jobs' }, - 'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' + 'Reminder jobs are disabled. Set WORKER_DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.' ) } if ( runtime.anonymousFeedbackEnabled && - householdConfigurationRepositoryClient && + workerHouseholdConfigurationRepositoryClient && telegramPendingActionRepositoryClient ) { registerAnonymousFeedback({ bot, anonymousFeedbackServiceForHousehold, - householdConfigurationRepository: householdConfigurationRepositoryClient!.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient!.repository, promptRepository: telegramPendingActionRepositoryClient!.repository, logger: getLogger('anonymous-feedback') }) @@ -448,19 +612,19 @@ if ( event: 'runtime.feature_disabled', feature: 'anonymous-feedback' }, - 'Anonymous feedback is disabled. Set DATABASE_URL to enable household and topic lookups.' + 'Anonymous feedback is disabled. Set WORKER_DATABASE_URL to enable household and topic lookups.' ) } if ( runtime.assistantEnabled && - householdConfigurationRepositoryClient && + workerHouseholdConfigurationRepositoryClient && telegramPendingActionRepositoryClient ) { if (processedBotMessageRepositoryClient) { registerDmAssistant({ bot, - householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient.repository, messageProcessingRepository: processedBotMessageRepositoryClient.repository, promptRepository: telegramPendingActionRepositoryClient.repository, financeServiceForHousehold, @@ -492,7 +656,7 @@ if ( } else { registerDmAssistant({ bot, - householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient.repository, promptRepository: telegramPendingActionRepositoryClient.repository, financeServiceForHousehold, memoryStore: assistantMemoryStore, @@ -523,10 +687,10 @@ if ( } } -if (householdConfigurationRepositoryClient && telegramPendingActionRepositoryClient) { +if (workerHouseholdConfigurationRepositoryClient && telegramPendingActionRepositoryClient) { registerReminderTopicUtilities({ bot, - householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + householdConfigurationRepository: workerHouseholdConfigurationRepositoryClient.repository, promptRepository: telegramPendingActionRepositoryClient.repository, financeServiceForHousehold, logger: getLogger('reminder-utilities') @@ -537,272 +701,272 @@ const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, webhookHandler, - miniAppAuth: householdOnboardingService + miniAppAuth: runtime.miniAppAuthEnabled ? createMiniAppAuthHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, logger: getLogger('miniapp-auth') }) : undefined, - miniAppJoin: householdOnboardingService + miniAppJoin: runtime.miniAppAuthEnabled ? createMiniAppJoinHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, logger: getLogger('miniapp-auth') }) : undefined, - miniAppDashboard: householdOnboardingService + miniAppDashboard: runtime.miniAppAuthEnabled ? createMiniAppDashboardHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - financeServiceForHousehold, - onboardingService: householdOnboardingService!, + financeServiceForSession: appFinanceServiceForSession, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, logger: getLogger('miniapp-dashboard') }) : undefined, - miniAppPendingMembers: householdOnboardingService + miniAppPendingMembers: runtime.miniAppAuthEnabled ? createMiniAppPendingMembersHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppApproveMember: householdOnboardingService + miniAppApproveMember: runtime.miniAppAuthEnabled ? createMiniAppApproveMemberHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppRejectMember: householdOnboardingService + miniAppRejectMember: runtime.miniAppAuthEnabled ? createMiniAppRejectMemberHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppSettings: householdOnboardingService + miniAppSettings: runtime.miniAppAuthEnabled ? createMiniAppSettingsHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, assistantUsageTracker, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateSettings: householdOnboardingService + miniAppUpdateSettings: runtime.miniAppAuthEnabled ? createMiniAppUpdateSettingsHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpsertUtilityCategory: householdOnboardingService + miniAppUpsertUtilityCategory: runtime.miniAppAuthEnabled ? createMiniAppUpsertUtilityCategoryHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppPromoteMember: householdOnboardingService + miniAppPromoteMember: runtime.miniAppAuthEnabled ? createMiniAppPromoteMemberHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateOwnDisplayName: householdOnboardingService + miniAppUpdateOwnDisplayName: runtime.miniAppAuthEnabled ? createMiniAppUpdateOwnDisplayNameHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateMemberDisplayName: householdOnboardingService + miniAppUpdateMemberDisplayName: runtime.miniAppAuthEnabled ? createMiniAppUpdateMemberDisplayNameHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateMemberRentWeight: householdOnboardingService + miniAppUpdateMemberRentWeight: runtime.miniAppAuthEnabled ? createMiniAppUpdateMemberRentWeightHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateMemberStatus: householdOnboardingService + miniAppUpdateMemberStatus: runtime.miniAppAuthEnabled ? createMiniAppUpdateMemberStatusHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppUpdateMemberAbsencePolicy: householdOnboardingService + miniAppUpdateMemberAbsencePolicy: runtime.miniAppAuthEnabled ? createMiniAppUpdateMemberAbsencePolicyHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - miniAppAdminService: miniAppAdminService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + miniAppAdminServiceForSession: appMiniAppAdminServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, - miniAppBillingCycle: householdOnboardingService + miniAppBillingCycle: runtime.miniAppAuthEnabled ? createMiniAppBillingCycleHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppOpenCycle: householdOnboardingService + miniAppOpenCycle: runtime.miniAppAuthEnabled ? createMiniAppOpenCycleHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppCloseCycle: householdOnboardingService + miniAppCloseCycle: runtime.miniAppAuthEnabled ? createMiniAppCloseCycleHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppRentUpdate: householdOnboardingService + miniAppRentUpdate: runtime.miniAppAuthEnabled ? createMiniAppRentUpdateHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppAddUtilityBill: householdOnboardingService + miniAppAddUtilityBill: runtime.miniAppAuthEnabled ? createMiniAppAddUtilityBillHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppSubmitUtilityBill: householdOnboardingService + miniAppSubmitUtilityBill: runtime.miniAppAuthEnabled ? createMiniAppSubmitUtilityBillHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppUpdateUtilityBill: householdOnboardingService + miniAppUpdateUtilityBill: runtime.miniAppAuthEnabled ? createMiniAppUpdateUtilityBillHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppDeleteUtilityBill: householdOnboardingService + miniAppDeleteUtilityBill: runtime.miniAppAuthEnabled ? createMiniAppDeleteUtilityBillHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppAddPurchase: householdOnboardingService + miniAppAddPurchase: runtime.miniAppAuthEnabled ? createMiniAppAddPurchaseHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppUpdatePurchase: householdOnboardingService + miniAppUpdatePurchase: runtime.miniAppAuthEnabled ? createMiniAppUpdatePurchaseHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppDeletePurchase: householdOnboardingService + miniAppDeletePurchase: runtime.miniAppAuthEnabled ? createMiniAppDeletePurchaseHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppAddPayment: householdOnboardingService + miniAppAddPayment: runtime.miniAppAuthEnabled ? createMiniAppAddPaymentHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppUpdatePayment: householdOnboardingService + miniAppUpdatePayment: runtime.miniAppAuthEnabled ? createMiniAppUpdatePaymentHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppDeletePayment: householdOnboardingService + miniAppDeletePayment: runtime.miniAppAuthEnabled ? createMiniAppDeletePaymentHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - financeServiceForHousehold, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + financeServiceForSession: appFinanceServiceForSession, logger: getLogger('miniapp-billing') }) : undefined, - miniAppLocalePreference: householdOnboardingService + miniAppLocalePreference: runtime.miniAppAuthEnabled ? createMiniAppLocalePreferenceHandler({ allowedOrigins: runtime.miniAppAllowedOrigins, botToken: runtime.telegramBotToken, - onboardingService: householdOnboardingService, - localePreferenceService: localePreferenceService!, + onboardingServiceForTelegramUserId: appOnboardingServiceForTelegramUserId, + localePreferenceServiceForSession: appLocalePreferenceServiceForSession, logger: getLogger('miniapp-admin') }) : undefined, diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index ee2ffca..9449d01 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -12,12 +12,59 @@ import type { AssistantUsageTracker } from './dm-assistant' import { allowedMiniAppOrigin, + type MiniAppAuthorizedSession, createMiniAppSessionService, miniAppErrorResponse, miniAppJsonResponse, readMiniAppRequestPayload } from './miniapp-auth' +interface MiniAppAdminHandlerBaseOptions { + allowedOrigins: readonly string[] + botToken: string + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService + miniAppAdminServiceForSession?: (session: MiniAppAuthorizedSession) => MiniAppAdminService + miniAppAdminService?: MiniAppAdminService + logger?: Logger +} + +function createConfiguredMiniAppSessionService(options: { + botToken: string + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService +}) { + return createMiniAppSessionService({ + botToken: options.botToken, + ...(options.onboardingServiceForTelegramUserId + ? { + onboardingServiceForTelegramUserId: options.onboardingServiceForTelegramUserId + } + : {}), + ...(options.onboardingService + ? { + onboardingService: options.onboardingService + } + : {}) + }) +} + +function resolveMiniAppAdminService( + options: Pick< + MiniAppAdminHandlerBaseOptions, + 'miniAppAdminServiceForSession' | 'miniAppAdminService' + >, + session: MiniAppAuthorizedSession +): MiniAppAdminService { + const service = options.miniAppAdminServiceForSession?.(session) ?? options.miniAppAdminService + + if (!service) { + throw new Error('Mini app admin service is not configured') + } + + return service +} + async function readApprovalPayload(request: Request): Promise<{ initData: string pendingTelegramUserId: string @@ -401,6 +448,7 @@ async function authenticateAdminSession( | Response | { member: NonNullable + telegramUserId: string } > { const payload = await readMiniAppRequestPayload(request) @@ -413,7 +461,7 @@ async function authenticateAdminSession( return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) } - if (!session.authorized || !session.member) { + if (!session.authorized || !session.member || !session.telegramUser) { return miniAppJsonResponse( { ok: false, error: 'Admin access required for active household members' }, 403, @@ -430,23 +478,15 @@ async function authenticateAdminSession( } return { - member: session.member + member: session.member, + telegramUserId: session.telegramUser.id } } -export function createMiniAppPendingMembersHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppPendingMembersHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -478,6 +518,7 @@ export function createMiniAppPendingMembersHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -488,7 +529,10 @@ export function createMiniAppPendingMembersHandler(options: { ) } - const result = await options.miniAppAdminService.listPendingMembers({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).listPendingMembers({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin }) @@ -513,20 +557,14 @@ export function createMiniAppPendingMembersHandler(options: { } } -export function createMiniAppSettingsHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - assistantUsageTracker?: AssistantUsageTracker - logger?: Logger -}): { +export function createMiniAppSettingsHandler( + options: MiniAppAdminHandlerBaseOptions & { + assistantUsageTracker?: AssistantUsageTracker + } +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -545,9 +583,12 @@ export function createMiniAppSettingsHandler(options: { if (auth instanceof Response) { return auth } - const { member } = auth + const { member, telegramUserId } = auth - const result = await options.miniAppAdminService.getSettings({ + const result = await resolveMiniAppAdminService(options, { + member, + telegramUserId + }).getSettings({ householdId: member.householdId, actorIsAdmin: member.isAdmin }) @@ -580,19 +621,10 @@ export function createMiniAppSettingsHandler(options: { } } -export function createMiniAppUpdateSettingsHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateSettingsHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -623,6 +655,7 @@ export function createMiniAppUpdateSettingsHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -633,7 +666,10 @@ export function createMiniAppUpdateSettingsHandler(options: { ) } - const result = await options.miniAppAdminService.updateSettings({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateSettings({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, ...(payload.householdName !== undefined @@ -715,19 +751,12 @@ export function createMiniAppUpdateSettingsHandler(options: { } } -export function createMiniAppUpsertUtilityCategoryHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpsertUtilityCategoryHandler( + options: MiniAppAdminHandlerBaseOptions +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -758,6 +787,7 @@ export function createMiniAppUpsertUtilityCategoryHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -768,7 +798,10 @@ export function createMiniAppUpsertUtilityCategoryHandler(options: { ) } - const result = await options.miniAppAdminService.upsertUtilityCategory({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).upsertUtilityCategory({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, ...(payload.slug @@ -811,19 +844,10 @@ export function createMiniAppUpsertUtilityCategoryHandler(options: { } } -export function createMiniAppPromoteMemberHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppPromoteMemberHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -854,6 +878,7 @@ export function createMiniAppPromoteMemberHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -864,7 +889,10 @@ export function createMiniAppPromoteMemberHandler(options: { ) } - const result = await options.miniAppAdminService.promoteMemberToAdmin({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).promoteMemberToAdmin({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, memberId: payload.memberId @@ -898,19 +926,10 @@ export function createMiniAppPromoteMemberHandler(options: { } } -export function createMiniAppUpdateOwnDisplayNameHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateOwnDisplayNameHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -930,7 +949,7 @@ export function createMiniAppUpdateOwnDisplayNameHandler(options: { initData: payload.initData }) - if (!session || !session.authorized || !session.member) { + if (!session || !session.authorized || !session.member || !session.telegramUser) { return miniAppJsonResponse( { ok: false, error: 'Active household membership required' }, session ? 403 : 401, @@ -938,7 +957,10 @@ export function createMiniAppUpdateOwnDisplayNameHandler(options: { ) } - const result = await options.miniAppAdminService.updateOwnDisplayName({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateOwnDisplayName({ householdId: session.member.householdId, actorMemberId: session.member.id, displayName: payload.displayName @@ -974,19 +996,12 @@ export function createMiniAppUpdateOwnDisplayNameHandler(options: { } } -export function createMiniAppUpdateMemberRentWeightHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateMemberRentWeightHandler( + options: MiniAppAdminHandlerBaseOptions +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1017,6 +1032,7 @@ export function createMiniAppUpdateMemberRentWeightHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1027,7 +1043,10 @@ export function createMiniAppUpdateMemberRentWeightHandler(options: { ) } - const result = await options.miniAppAdminService.updateMemberRentShareWeight({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateMemberRentShareWeight({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, memberId: payload.memberId, @@ -1070,19 +1089,12 @@ export function createMiniAppUpdateMemberRentWeightHandler(options: { } } -export function createMiniAppUpdateMemberDisplayNameHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateMemberDisplayNameHandler( + options: MiniAppAdminHandlerBaseOptions +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1106,6 +1118,7 @@ export function createMiniAppUpdateMemberDisplayNameHandler(options: { !session || !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1120,7 +1133,10 @@ export function createMiniAppUpdateMemberDisplayNameHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing memberId' }, 400, origin) } - const result = await options.miniAppAdminService.updateMemberDisplayName({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateMemberDisplayName({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, memberId: payload.memberId, @@ -1163,19 +1179,10 @@ export function createMiniAppUpdateMemberDisplayNameHandler(options: { } } -export function createMiniAppUpdateMemberStatusHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateMemberStatusHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1199,6 +1206,7 @@ export function createMiniAppUpdateMemberStatusHandler(options: { !session || !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1209,7 +1217,10 @@ export function createMiniAppUpdateMemberStatusHandler(options: { ) } - const result = await options.miniAppAdminService.updateMemberStatus({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateMemberStatus({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, memberId: payload.memberId, @@ -1244,19 +1255,12 @@ export function createMiniAppUpdateMemberStatusHandler(options: { } } -export function createMiniAppUpdateMemberAbsencePolicyHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppUpdateMemberAbsencePolicyHandler( + options: MiniAppAdminHandlerBaseOptions +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1280,6 +1284,7 @@ export function createMiniAppUpdateMemberAbsencePolicyHandler(options: { !session || !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1290,7 +1295,10 @@ export function createMiniAppUpdateMemberAbsencePolicyHandler(options: { ) } - const result = await options.miniAppAdminService.updateMemberAbsencePolicy({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).updateMemberAbsencePolicy({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, memberId: payload.memberId, @@ -1325,19 +1333,10 @@ export function createMiniAppUpdateMemberAbsencePolicyHandler(options: { } } -export function createMiniAppApproveMemberHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppApproveMemberHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1368,6 +1367,7 @@ export function createMiniAppApproveMemberHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1378,7 +1378,10 @@ export function createMiniAppApproveMemberHandler(options: { ) } - const result = await options.miniAppAdminService.approvePendingMember({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).approvePendingMember({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, pendingTelegramUserId: payload.pendingTelegramUserId @@ -1410,19 +1413,10 @@ export function createMiniAppApproveMemberHandler(options: { } } -export function createMiniAppRejectMemberHandler(options: { - allowedOrigins: readonly string[] - botToken: string - onboardingService: HouseholdOnboardingService - miniAppAdminService: MiniAppAdminService - logger?: Logger -}): { +export function createMiniAppRejectMemberHandler(options: MiniAppAdminHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppSessionService(options) return { handler: async (request) => { @@ -1453,6 +1447,7 @@ export function createMiniAppRejectMemberHandler(options: { if ( !session.authorized || !session.member || + !session.telegramUser || session.member.status !== 'active' || !session.member.isAdmin ) { @@ -1463,7 +1458,10 @@ export function createMiniAppRejectMemberHandler(options: { ) } - const result = await options.miniAppAdminService.rejectPendingMember({ + const result = await resolveMiniAppAdminService(options, { + member: session.member, + telegramUserId: session.telegramUser.id + }).rejectPendingMember({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, pendingTelegramUserId: payload.pendingTelegramUserId diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts index 091a1b9..ae2b132 100644 --- a/apps/bot/src/miniapp-auth.ts +++ b/apps/bot/src/miniapp-auth.ts @@ -115,12 +115,28 @@ export interface MiniAppSessionResult { } } +export interface MiniAppAuthorizedSession { + member: NonNullable + telegramUserId: string +} + export function createMiniAppSessionService(options: { botToken: string - onboardingService: HouseholdOnboardingService + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService }): { authenticate: (payload: MiniAppRequestPayload) => Promise } { + const resolveOnboardingService = + options.onboardingServiceForTelegramUserId ?? + (() => { + if (!options.onboardingService) { + throw new Error('Mini app onboarding service is not configured') + } + + return options.onboardingService + }) + return { authenticate: async (payload) => { if (!payload.initData) { @@ -132,7 +148,7 @@ export function createMiniAppSessionService(options: { return null } - const access = await options.onboardingService.getMiniAppAccess({ + const access = await resolveOnboardingService(telegramUser.id).getMiniAppAccess({ identity: { telegramUserId: telegramUser.id, displayName: @@ -190,14 +206,24 @@ export function createMiniAppSessionService(options: { export function createMiniAppAuthHandler(options: { allowedOrigins: readonly string[] botToken: string - onboardingService: HouseholdOnboardingService + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService logger?: Logger }): { handler: (request: Request) => Promise } { const sessionService = createMiniAppSessionService({ botToken: options.botToken, - onboardingService: options.onboardingService + ...(options.onboardingServiceForTelegramUserId + ? { + onboardingServiceForTelegramUserId: options.onboardingServiceForTelegramUserId + } + : {}), + ...(options.onboardingService + ? { + onboardingService: options.onboardingService + } + : {}) }) return { @@ -264,7 +290,8 @@ export function createMiniAppAuthHandler(options: { export function createMiniAppJoinHandler(options: { allowedOrigins: readonly string[] botToken: string - onboardingService: HouseholdOnboardingService + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService logger?: Logger }): { handler: (request: Request) => Promise @@ -304,7 +331,13 @@ export function createMiniAppJoinHandler(options: { ) } - const result = await options.onboardingService.joinHousehold({ + const onboardingService = + options.onboardingServiceForTelegramUserId?.(telegramUser.id) ?? options.onboardingService + if (!onboardingService) { + throw new Error('Mini app onboarding service is not configured') + } + + const result = await onboardingService.joinHousehold({ identity: { telegramUserId: telegramUser.id, displayName: diff --git a/apps/bot/src/miniapp-billing.ts b/apps/bot/src/miniapp-billing.ts index 87fb554..8fd6206 100644 --- a/apps/bot/src/miniapp-billing.ts +++ b/apps/bot/src/miniapp-billing.ts @@ -2,7 +2,7 @@ import type { FinanceCommandService, HouseholdOnboardingService } from '@househo import { BillingPeriod } from '@household/domain' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository } from '@household/ports' -import type { MiniAppSessionResult } from './miniapp-auth' +import type { MiniAppAuthorizedSession, MiniAppSessionResult } from './miniapp-auth' import { allowedMiniAppOrigin, @@ -12,6 +12,54 @@ import { readMiniAppRequestPayload } from './miniapp-auth' +interface MiniAppBillingHandlerBaseOptions { + allowedOrigins: readonly string[] + botToken: string + financeServiceForSession?: (session: MiniAppAuthorizedSession) => FinanceCommandService + financeServiceForHousehold?: (householdId: string) => FinanceCommandService + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService + logger?: Logger +} + +function createConfiguredMiniAppBillingSessionService(options: { + botToken: string + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService +}) { + return createMiniAppSessionService({ + botToken: options.botToken, + ...(options.onboardingServiceForTelegramUserId + ? { + onboardingServiceForTelegramUserId: options.onboardingServiceForTelegramUserId + } + : {}), + ...(options.onboardingService + ? { + onboardingService: options.onboardingService + } + : {}) + }) +} + +function resolveFinanceService( + options: Pick< + MiniAppBillingHandlerBaseOptions, + 'financeServiceForSession' | 'financeServiceForHousehold' + >, + session: MiniAppAuthorizedSession +): FinanceCommandService { + const service = + options.financeServiceForSession?.(session) ?? + options.financeServiceForHousehold?.(session.member.householdId) + + if (!service) { + throw new Error('Mini app finance service is not configured') + } + + return service +} + function serializeCycleState( state: Awaited> ) { @@ -42,6 +90,7 @@ async function authenticateAdminSession( | Response | { member: NonNullable + telegramUserId: string } > { const payload = await readMiniAppRequestPayload(request) @@ -54,7 +103,7 @@ async function authenticateAdminSession( return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) } - if (!session.authorized || !session.member) { + if (!session.authorized || !session.member || !session.telegramUser) { return miniAppJsonResponse( { ok: false, error: 'Access limited to active household members' }, 403, @@ -67,7 +116,8 @@ async function authenticateAdminSession( } return { - member: session.member + member: session.member, + telegramUserId: session.telegramUser.id } } @@ -79,6 +129,7 @@ async function authenticateMemberSession( | Response | { member: NonNullable + telegramUserId: string } > { const payload = await readMiniAppRequestPayload(request) @@ -91,7 +142,12 @@ async function authenticateMemberSession( return miniAppJsonResponse({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) } - if (!session.authorized || !session.member || session.member.status !== 'active') { + if ( + !session.authorized || + !session.member || + !session.telegramUser || + session.member.status !== 'active' + ) { return miniAppJsonResponse( { ok: false, error: 'Access limited to active household members' }, 403, @@ -100,7 +156,8 @@ async function authenticateMemberSession( } return { - member: session.member + member: session.member, + telegramUserId: session.telegramUser.id } } @@ -530,19 +587,10 @@ async function readPaymentMutationPayload(request: Request): Promise<{ } } -export function createMiniAppBillingCycleHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppBillingCycleHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -567,9 +615,9 @@ export function createMiniAppBillingCycleHandler(options: { } const payload = await readCycleQueryPayload(request) - const cycleState = await options - .financeServiceForHousehold(auth.member.householdId) - .getAdminCycleState(payload.period) + const cycleState = await resolveFinanceService(options, auth).getAdminCycleState( + payload.period + ) return miniAppJsonResponse( { @@ -587,19 +635,10 @@ export function createMiniAppBillingCycleHandler(options: { } } -export function createMiniAppOpenCycleHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppOpenCycleHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -624,7 +663,7 @@ export function createMiniAppOpenCycleHandler(options: { } const payload = await readOpenCyclePayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) await service.openCycle(payload.period, payload.currency) const cycleState = await service.getAdminCycleState(payload.period) @@ -644,19 +683,10 @@ export function createMiniAppOpenCycleHandler(options: { } } -export function createMiniAppCloseCycleHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppCloseCycleHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -681,7 +711,7 @@ export function createMiniAppCloseCycleHandler(options: { } const payload = await readCycleQueryPayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) await service.closeCycle(payload.period) const cycleState = await service.getAdminCycleState() @@ -701,19 +731,10 @@ export function createMiniAppCloseCycleHandler(options: { } } -export function createMiniAppRentUpdateHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppRentUpdateHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -738,7 +759,7 @@ export function createMiniAppRentUpdateHandler(options: { } const payload = await readRentUpdatePayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const result = await service.setRent(payload.amountMajor, payload.currency, payload.period) if (!result) { return miniAppJsonResponse( @@ -766,19 +787,10 @@ export function createMiniAppRentUpdateHandler(options: { } } -export function createMiniAppAddUtilityBillHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppAddUtilityBillHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -803,7 +815,7 @@ export function createMiniAppAddUtilityBillHandler(options: { } const payload = await readUtilityBillPayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const result = await service.addUtilityBill( payload.billName, payload.amountMajor, @@ -837,19 +849,10 @@ export function createMiniAppAddUtilityBillHandler(options: { } } -export function createMiniAppSubmitUtilityBillHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppSubmitUtilityBillHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -874,7 +877,7 @@ export function createMiniAppSubmitUtilityBillHandler(options: { } const payload = await readUtilityBillPayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const result = await service.addUtilityBill( payload.billName, payload.amountMajor, @@ -905,35 +908,33 @@ export function createMiniAppSubmitUtilityBillHandler(options: { } } -export function createMiniAppSubmitPaymentHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - householdConfigurationRepository: HouseholdConfigurationRepository - logger?: Logger -}): { +export function createMiniAppSubmitPaymentHandler( + options: MiniAppBillingHandlerBaseOptions & { + householdConfigurationRepositoryForSession: ( + session: MiniAppAuthorizedSession + ) => HouseholdConfigurationRepository + } +): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) - async function notifyPaymentRecorded(input: { - householdId: string - memberName: string - kind: 'rent' | 'utilities' - amountMajor: string - currency: string - period: string - }) { + async function notifyPaymentRecorded( + session: MiniAppAuthorizedSession, + input: { + householdId: string + memberName: string + kind: 'rent' | 'utilities' + amountMajor: string + currency: string + period: string + } + ) { + const householdConfigurationRepository = + options.householdConfigurationRepositoryForSession(session) const [chat, topic] = await Promise.all([ - options.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), - options.householdConfigurationRepository.getHouseholdTopicBinding( - input.householdId, - 'reminders' - ) + householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), + householdConfigurationRepository.getHouseholdTopicBinding(input.householdId, 'reminders') ]) if (!chat || !topic) { @@ -996,7 +997,7 @@ export function createMiniAppSubmitPaymentHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const payment = await service.addPayment( auth.member.id, payload.kind, @@ -1008,7 +1009,7 @@ export function createMiniAppSubmitPaymentHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Failed to record payment' }, 500, origin) } - await notifyPaymentRecorded({ + await notifyPaymentRecorded(auth, { householdId: auth.member.householdId, memberName: auth.member.displayName, kind: payload.kind, @@ -1032,19 +1033,10 @@ export function createMiniAppSubmitPaymentHandler(options: { } } -export function createMiniAppUpdateUtilityBillHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppUpdateUtilityBillHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1069,7 +1061,7 @@ export function createMiniAppUpdateUtilityBillHandler(options: { } const payload = await readUtilityBillUpdatePayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const result = await service.updateUtilityBill( payload.billId, payload.billName, @@ -1099,19 +1091,10 @@ export function createMiniAppUpdateUtilityBillHandler(options: { } } -export function createMiniAppDeleteUtilityBillHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppDeleteUtilityBillHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1136,7 +1119,7 @@ export function createMiniAppDeleteUtilityBillHandler(options: { } const payload = await readUtilityBillDeletePayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const deleted = await service.deleteUtilityBill(payload.billId) if (!deleted) { @@ -1161,19 +1144,10 @@ export function createMiniAppDeleteUtilityBillHandler(options: { } } -export function createMiniAppAddPurchaseHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppAddPurchaseHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1200,7 +1174,7 @@ export function createMiniAppAddPurchaseHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing purchase fields' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const payerMemberId = payload.payerMemberId ?? auth.member.id await service.addPurchase( payload.description, @@ -1218,19 +1192,10 @@ export function createMiniAppAddPurchaseHandler(options: { } } -export function createMiniAppUpdatePurchaseHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppUpdatePurchaseHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1257,7 +1222,7 @@ export function createMiniAppUpdatePurchaseHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing purchase fields' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const payerMemberId = payload.payerMemberId const updated = await service.updatePurchase( payload.purchaseId, @@ -1280,19 +1245,10 @@ export function createMiniAppUpdatePurchaseHandler(options: { } } -export function createMiniAppDeletePurchaseHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppDeletePurchaseHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1315,7 +1271,7 @@ export function createMiniAppDeletePurchaseHandler(options: { } const payload = await readPurchaseMutationPayload(request) - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const deleted = await service.deletePurchase(payload.purchaseId) if (!deleted) { @@ -1330,19 +1286,10 @@ export function createMiniAppDeletePurchaseHandler(options: { } } -export function createMiniAppAddPaymentHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppAddPaymentHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1369,7 +1316,7 @@ export function createMiniAppAddPaymentHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const payment = await service.addPayment( payload.memberId, payload.kind, @@ -1389,19 +1336,10 @@ export function createMiniAppAddPaymentHandler(options: { } } -export function createMiniAppUpdatePaymentHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppUpdatePaymentHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1428,7 +1366,7 @@ export function createMiniAppUpdatePaymentHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing payment fields' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const payment = await service.updatePayment( payload.paymentId, payload.memberId, @@ -1449,19 +1387,10 @@ export function createMiniAppUpdatePaymentHandler(options: { } } -export function createMiniAppDeletePaymentHandler(options: { - allowedOrigins: readonly string[] - botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService - logger?: Logger -}): { +export function createMiniAppDeletePaymentHandler(options: MiniAppBillingHandlerBaseOptions): { handler: (request: Request) => Promise } { - const sessionService = createMiniAppSessionService({ - botToken: options.botToken, - onboardingService: options.onboardingService - }) + const sessionService = createConfiguredMiniAppBillingSessionService(options) return { handler: async (request) => { @@ -1488,7 +1417,7 @@ export function createMiniAppDeletePaymentHandler(options: { return miniAppJsonResponse({ ok: false, error: 'Missing payment id' }, 400, origin) } - const service = options.financeServiceForHousehold(auth.member.householdId) + const service = resolveFinanceService(options, auth) const deleted = await service.deletePayment(payload.paymentId) if (!deleted) { diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 7716a8a..15299b0 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -3,6 +3,7 @@ import type { Logger } from '@household/observability' import { allowedMiniAppOrigin, + type MiniAppAuthorizedSession, createMiniAppSessionService, miniAppErrorResponse, miniAppJsonResponse, @@ -12,15 +13,26 @@ import { export function createMiniAppDashboardHandler(options: { allowedOrigins: readonly string[] botToken: string - financeServiceForHousehold: (householdId: string) => FinanceCommandService - onboardingService: HouseholdOnboardingService + financeServiceForSession?: (session: MiniAppAuthorizedSession) => FinanceCommandService + financeServiceForHousehold?: (householdId: string) => FinanceCommandService + onboardingServiceForTelegramUserId?: (telegramUserId: string) => HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService logger?: Logger }): { handler: (request: Request) => Promise } { const sessionService = createMiniAppSessionService({ botToken: options.botToken, - onboardingService: options.onboardingService + ...(options.onboardingServiceForTelegramUserId + ? { + onboardingServiceForTelegramUserId: options.onboardingServiceForTelegramUserId + } + : {}), + ...(options.onboardingService + ? { + onboardingService: options.onboardingService + } + : {}) }) return { @@ -62,7 +74,7 @@ export function createMiniAppDashboardHandler(options: { ) } - if (!session.member) { + if (!session.member || !session.telegramUser) { return miniAppJsonResponse( { ok: false, error: 'Authenticated session is missing member context' }, 500, @@ -70,9 +82,17 @@ export function createMiniAppDashboardHandler(options: { ) } - const dashboard = await options - .financeServiceForHousehold(session.member.householdId) - .generateDashboard() + const financeService = + options.financeServiceForSession?.({ + member: session.member, + telegramUserId: session.telegramUser.id + }) ?? options.financeServiceForHousehold?.(session.member.householdId) + + if (!financeService) { + throw new Error('Mini app finance service is not configured') + } + + const dashboard = await financeService.generateDashboard() if (!dashboard) { return miniAppJsonResponse( { ok: false, error: 'No billing cycle available' }, diff --git a/apps/bot/src/miniapp-locale.ts b/apps/bot/src/miniapp-locale.ts index 725ce96..dc85112 100644 --- a/apps/bot/src/miniapp-locale.ts +++ b/apps/bot/src/miniapp-locale.ts @@ -4,6 +4,7 @@ import type { Logger } from '@household/observability' import { allowedMiniAppOrigin, + type MiniAppAuthorizedSession, createMiniAppSessionService, miniAppErrorResponse, miniAppJsonResponse @@ -53,15 +54,26 @@ async function readLocalePreferenceRequest(request: Request): Promise HouseholdOnboardingService + onboardingService?: HouseholdOnboardingService + localePreferenceServiceForSession?: (session: MiniAppAuthorizedSession) => LocalePreferenceService + localePreferenceService?: LocalePreferenceService logger?: Logger }): { handler: (request: Request) => Promise } { const sessionService = createMiniAppSessionService({ botToken: options.botToken, - onboardingService: options.onboardingService + ...(options.onboardingServiceForTelegramUserId + ? { + onboardingServiceForTelegramUserId: options.onboardingServiceForTelegramUserId + } + : {}), + ...(options.onboardingService + ? { + onboardingService: options.onboardingService + } + : {}) }) return { @@ -100,9 +112,18 @@ export function createMiniAppLocalePreferenceHandler(options: { let memberPreferredLocale = session.member.preferredLocale let householdDefaultLocale = session.member.householdDefaultLocale + const localePreferenceService = + options.localePreferenceServiceForSession?.({ + member: session.member, + telegramUserId: session.telegramUser.id + }) ?? options.localePreferenceService + + if (!localePreferenceService) { + throw new Error('Mini app locale preference service is not configured') + } if (payload.scope === 'member') { - const result = await options.localePreferenceService.updateMemberLocale({ + const result = await localePreferenceService.updateMemberLocale({ householdId: session.member.householdId, telegramUserId: session.telegramUser.id, locale: payload.locale @@ -115,7 +136,7 @@ export function createMiniAppLocalePreferenceHandler(options: { memberPreferredLocale = result.member.preferredLocale householdDefaultLocale = result.member.householdDefaultLocale } else { - const result = await options.localePreferenceService.updateHouseholdLocale({ + const result = await localePreferenceService.updateHouseholdLocale({ householdId: session.member.householdId, actorIsAdmin: session.member.isAdmin, locale: payload.locale diff --git a/docs/runbooks/dev-setup.md b/docs/runbooks/dev-setup.md index 52808d6..73f7cd8 100644 --- a/docs/runbooks/dev-setup.md +++ b/docs/runbooks/dev-setup.md @@ -64,12 +64,13 @@ codex review --base origin/main - Copy `.env.example` to `.env` before running app/database commands. - `bun run db:seed` refreshes the committed fixture household and is destructive for previously seeded fixture rows. - Local bot feature flags come from env presence: - - finance commands require `DATABASE_URL` plus household setup in Telegram via `/setup` - - purchase ingestion requires `DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic` - - anonymous feedback requires `DATABASE_URL` plus a bound feedback topic via `/bind_feedback_topic` - - reminders require `DATABASE_URL` plus `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS` + - mini app auth and mini app API routes require `APP_DATABASE_URL` + - finance commands require `WORKER_DATABASE_URL` plus household setup in Telegram via `/setup` + - purchase ingestion requires `WORKER_DATABASE_URL` plus a bound purchase topic via `/bind_purchase_topic` + - anonymous feedback requires `WORKER_DATABASE_URL` plus a bound feedback topic via `/bind_feedback_topic` + - reminders require `WORKER_DATABASE_URL` plus `SCHEDULER_SHARED_SECRET` or `SCHEDULER_OIDC_ALLOWED_EMAILS` and optionally use a dedicated reminders topic via `/bind_reminders_topic` - - mini app CORS can be constrained with `MINI_APP_ALLOWED_ORIGINS` + - mini app CORS must be set explicitly with `MINI_APP_ALLOWED_ORIGINS` - Migration workflow is documented in `docs/runbooks/migrations.md`. - Destructive dev reset guidance is documented in `docs/runbooks/dev-reset.md`. - First deploy flow is documented in `docs/runbooks/first-deploy.md`. diff --git a/docs/runbooks/first-deploy.md b/docs/runbooks/first-deploy.md index 810534e..c276804 100644 --- a/docs/runbooks/first-deploy.md +++ b/docs/runbooks/first-deploy.md @@ -12,7 +12,10 @@ Execute the first real deployment with a repeatable sequence that covers infrast - GCP project - GitHub repo settings - Telegram bot token - - Supabase project and database URL + - Supabase project and three database URLs: + - owner `DATABASE_URL` for migrations only + - `APP_DATABASE_URL` for authenticated request paths + - `WORKER_DATABASE_URL` for bot and scheduler workers ## Required Configuration Inventory @@ -28,7 +31,8 @@ Required in your environment `*.tfvars`: Recommended: -- `database_url_secret_id = "database-url"` +- `app_database_url_secret_id = "app-database-url"` +- `worker_database_url_secret_id = "worker-database-url"` - `telegram_bot_token_secret_id = "telegram-bot-token"` - `openai_api_key_secret_id = "openai-api-key"` - `bot_mini_app_allowed_origins` @@ -46,10 +50,9 @@ Create the secret resources via Terraform, then add secret versions for: - `telegram-bot-token` - `telegram-webhook-secret` - `scheduler-shared-secret` -- `database-url` +- `app-database-url` +- `worker-database-url` - optional `openai-api-key` -- optional `supabase-url` -- optional `supabase-publishable-key` ### GitHub Actions secrets @@ -129,15 +132,19 @@ Use the real project ID from Terraform variables: echo -n "" | gcloud secrets versions add telegram-bot-token --data-file=- --project echo -n "" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project echo -n "" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project -echo -n "" | gcloud secrets versions add database-url --data-file=- --project +echo -n "" | gcloud secrets versions add app-database-url --data-file=- --project +echo -n "" | gcloud secrets versions add worker-database-url --data-file=- --project ``` Add optional secret versions only if those integrations are enabled. -For a functional household dev deployment, set `database_url_secret_id = "database-url"` in -`dev.tfvars` before the apply that creates the Cloud Run services. Otherwise the bot deploys -without `DATABASE_URL`, and finance commands, reminders, mini app auth/dashboard, and anonymous -feedback remain disabled. +For a functional household deployment, set both `app_database_url_secret_id` and +`worker_database_url_secret_id` in `dev.tfvars` before the apply that creates the Cloud Run +services. Otherwise the bot deploys without `APP_DATABASE_URL` and `WORKER_DATABASE_URL`, and mini +app auth, finance commands, reminders, purchase ingestion, and anonymous feedback remain disabled. + +Keep `DATABASE_URL` out of normal runtime secrets. It is only required in GitHub Actions for the +migration step that runs before deploy. Keep `telegram_bot_token_secret_id = "telegram-bot-token"` aligned with the actual bot token secret name. CD uses that secret to sync the Telegram command menu after deploy. @@ -218,6 +225,9 @@ The smoke script verifies: - scheduler endpoint rejects unauthenticated requests - Telegram webhook matches the expected URL when bot token is provided +Production deploys should also set `MINI_APP_ALLOWED_ORIGINS` explicitly. The browser path remains +bot API only; there is no supported direct browser access to Supabase. + ## Phase 8: Scheduler Enablement First release: diff --git a/docs/runbooks/migrations.md b/docs/runbooks/migrations.md index ebcf761..b00203c 100644 --- a/docs/runbooks/migrations.md +++ b/docs/runbooks/migrations.md @@ -47,9 +47,16 @@ bun run build ## CD behavior -- CD deploy runs migrations before deploy and now requires the `DATABASE_URL` GitHub secret. +- CD deploy runs migrations before deploy and requires the owner-only `DATABASE_URL` GitHub secret. - If `DATABASE_URL` is missing, CD fails fast instead of deploying schema-dependent code without migrations. +## Runtime connection split + +- `DATABASE_URL` is for migrations, schema checks, and other owner-only maintenance tasks. +- `APP_DATABASE_URL` is for authenticated request paths such as mini app routes. +- `WORKER_DATABASE_URL` is for Telegram ingestion, reminders, scheduler jobs, and other internal worker flows. +- Runtime services should not use `DATABASE_URL`. + ## Safety rules - Prefer additive migrations first (new columns/tables) over destructive changes. diff --git a/docs/specs/HOUSEBOT-007-terraform-iac-baseline.md b/docs/specs/HOUSEBOT-007-terraform-iac-baseline.md index 42f3fad..cfeca60 100644 --- a/docs/specs/HOUSEBOT-007-terraform-iac-baseline.md +++ b/docs/specs/HOUSEBOT-007-terraform-iac-baseline.md @@ -29,8 +29,8 @@ Define a reproducible GCP infrastructure baseline for deployment of the bot API - Bot runtime reads secret-backed env vars: - `TELEGRAM_WEBHOOK_SECRET` - `SCHEDULER_SHARED_SECRET` - - `SUPABASE_URL` (optional) - - `SUPABASE_PUBLISHABLE_KEY` (optional) + - `APP_DATABASE_URL` (optional) + - `WORKER_DATABASE_URL` (optional) ## Domain Rules diff --git a/infra/terraform/README.md b/infra/terraform/README.md index f0ece45..1c75ccc 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -58,8 +58,9 @@ echo -n "" | gcloud secrets versions add telegram-webhook-secret --data-f echo -n "" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project ``` -If you configure optional secret IDs such as `database_url_secret_id` or -`openai_api_key_secret_id`, add versions for those secrets as well. +If you configure optional secret IDs such as `app_database_url_secret_id`, +`worker_database_url_secret_id`, or `openai_api_key_secret_id`, add versions for those secrets as +well. If GitHub OIDC deploy access is enabled, keep `telegram_bot_token_secret_id` aligned with the real bot token secret name so CD can read it and sync Telegram commands automatically. @@ -84,6 +85,9 @@ Recommended approach: `bot_assistant_rate_limit_rolling_window_ms` - optional `bot_mini_app_allowed_origins` - optional `alert_notification_emails` + - runtime DB URLs should stay split: + `APP_DATABASE_URL` for authenticated request flows and `WORKER_DATABASE_URL` for background + workers ## Alerting baseline @@ -115,3 +119,4 @@ CI runs: - Scheduler jobs default to `paused = true` and `dry_run = true` to prevent accidental sends before live reminder delivery is ready. - Bot API is public to accept Telegram webhooks; scheduler endpoint should still verify app-level auth. - `bot_mini_app_allowed_origins` cannot be auto-derived in Terraform because the bot and mini app Cloud Run services reference each other; set it explicitly once the mini app URL is known. +- `DATABASE_URL` is migration-only and should not be injected into the bot runtime service. diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf index 67f9142..794d5ab 100644 --- a/infra/terraform/locals.tf +++ b/infra/terraform/locals.tf @@ -30,7 +30,8 @@ locals { runtime_secret_ids = toset(compact([ var.telegram_webhook_secret_id, var.scheduler_shared_secret_id, - var.database_url_secret_id, + var.app_database_url_secret_id, + var.worker_database_url_secret_id, var.telegram_bot_token_secret_id, var.openai_api_key_secret_id ])) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 96cd4f2..7ec4c03 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -179,8 +179,11 @@ module "bot_api_service" { TELEGRAM_WEBHOOK_SECRET = var.telegram_webhook_secret_id SCHEDULER_SHARED_SECRET = var.scheduler_shared_secret_id }, - var.database_url_secret_id == null ? {} : { - DATABASE_URL = var.database_url_secret_id + var.app_database_url_secret_id == null ? {} : { + APP_DATABASE_URL = var.app_database_url_secret_id + }, + var.worker_database_url_secret_id == null ? {} : { + WORKER_DATABASE_URL = var.worker_database_url_secret_id }, var.telegram_bot_token_secret_id == null ? {} : { TELEGRAM_BOT_TOKEN = var.telegram_bot_token_secret_id diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 925d8d2..2329cfb 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -8,7 +8,8 @@ artifact_repository_id = "household-bot" bot_api_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/bot:latest" mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/miniapp:latest" -database_url_secret_id = "database-url" +app_database_url_secret_id = "app-database-url" +worker_database_url_secret_id = "worker-database-url" telegram_bot_token_secret_id = "telegram-bot-token" openai_api_key_secret_id = "openai-api-key" bot_purchase_parser_model = "gpt-4o-mini" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index bf685a5..6b19d77 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -57,7 +57,21 @@ variable "scheduler_shared_secret_id" { } variable "database_url_secret_id" { - description = "Optional Secret Manager ID for DATABASE_URL" + description = "Optional Secret Manager ID for owner-only DATABASE_URL used outside runtime deploys" + type = string + default = null + nullable = true +} + +variable "app_database_url_secret_id" { + description = "Optional Secret Manager ID for APP_DATABASE_URL" + type = string + default = null + nullable = true +} + +variable "worker_database_url_secret_id" { + description = "Optional Secret Manager ID for WORKER_DATABASE_URL" type = string default = null nullable = true diff --git a/packages/adapters-db/src/finance-repository.ts b/packages/adapters-db/src/finance-repository.ts index ac87750..388b996 100644 --- a/packages/adapters-db/src/finance-repository.ts +++ b/packages/adapters-db/src/finance-repository.ts @@ -1,6 +1,6 @@ import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from 'drizzle-orm' -import { createDbClient, schema } from '@household/db' +import { createDbClient, type DbSessionContext, schema } from '@household/db' import type { FinanceRepository } from '@household/ports' import { instantFromDatabaseValue, @@ -22,14 +22,22 @@ function toCurrencyCode(raw: string): CurrencyCode { export function createDbFinanceRepository( databaseUrl: string, - householdId: string + householdId: string, + options: { + sessionContext?: DbSessionContext + } = {} ): { repository: FinanceRepository close: () => Promise } { const { db, queryClient } = createDbClient(databaseUrl, { max: 5, - prepare: false + prepare: false, + ...(options.sessionContext + ? { + sessionContext: options.sessionContext + } + : {}) }) async function loadPurchaseParticipants(purchaseIds: readonly string[]): Promise< diff --git a/packages/adapters-db/src/household-config-repository.ts b/packages/adapters-db/src/household-config-repository.ts index 420152d..20cfc5a 100644 --- a/packages/adapters-db/src/household-config-repository.ts +++ b/packages/adapters-db/src/household-config-repository.ts @@ -1,6 +1,6 @@ import { and, asc, eq, sql } from 'drizzle-orm' -import { createDbClient, schema } from '@household/db' +import { createDbClient, type DbSessionContext, schema } from '@household/db' import { instantToDate, normalizeSupportedLocale, @@ -334,13 +334,23 @@ function utilityCategorySlug(name: string): string { .slice(0, 48) } -export function createDbHouseholdConfigurationRepository(databaseUrl: string): { +export function createDbHouseholdConfigurationRepository( + databaseUrl: string, + options: { + sessionContext?: DbSessionContext + } = {} +): { repository: HouseholdConfigurationRepository close: () => Promise } { const { db, queryClient } = createDbClient(databaseUrl, { max: 5, - prepare: false + prepare: false, + ...(options.sessionContext + ? { + sessionContext: options.sessionContext + } + : {}) }) const defaultUtilityCategories = [ diff --git a/packages/db/drizzle-checksums.json b/packages/db/drizzle-checksums.json index 8139b59..9619b6b 100644 --- a/packages/db/drizzle-checksums.json +++ b/packages/db/drizzle-checksums.json @@ -23,6 +23,7 @@ "0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641", "0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1", "0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad", - "0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1" + "0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1", + "0022_harden_rls.sql": "d2e24b3e5b7ec7ef9da7e90c0ddf0e408764f3578af3872f76b9b3198ffbd70e" } } diff --git a/packages/db/drizzle/0022_harden_rls.sql b/packages/db/drizzle/0022_harden_rls.sql new file mode 100644 index 0000000..ed9d261 --- /dev/null +++ b/packages/db/drizzle/0022_harden_rls.sql @@ -0,0 +1,1225 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'housebot_app') THEN + CREATE ROLE housebot_app LOGIN NOINHERIT NOBYPASSRLS; + ELSE + ALTER ROLE housebot_app LOGIN NOINHERIT NOBYPASSRLS; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'housebot_worker') THEN + CREATE ROLE housebot_worker LOGIN NOINHERIT NOBYPASSRLS; + ELSE + ALTER ROLE housebot_worker LOGIN NOINHERIT NOBYPASSRLS; + END IF; +END +$$; + +REVOKE ALL ON SCHEMA public FROM PUBLIC; + +GRANT USAGE ON SCHEMA public TO housebot_app; +GRANT USAGE ON SCHEMA public TO housebot_worker; + +REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC; + +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO housebot_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO housebot_worker; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO housebot_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO housebot_worker; + +DO $$ +DECLARE + target_role text; +BEGIN + FOREACH target_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] + LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target_role) THEN + EXECUTE format('REVOKE ALL ON SCHEMA public FROM %I', target_role); + EXECUTE format('REVOKE ALL ON ALL TABLES IN SCHEMA public FROM %I', target_role); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM %I', + target_role + ); + END IF; + END LOOP; +END +$$; + +CREATE OR REPLACE FUNCTION public.app_current_telegram_user_id() +RETURNS text +LANGUAGE sql +STABLE +AS $$ + SELECT nullif(current_setting('app.telegram_user_id', true), '') +$$; + +CREATE OR REPLACE FUNCTION public.app_current_household_id() +RETURNS uuid +LANGUAGE sql +STABLE +AS $$ + SELECT nullif(current_setting('app.household_id', true), '')::uuid +$$; + +CREATE OR REPLACE FUNCTION public.app_current_member_id() +RETURNS uuid +LANGUAGE sql +STABLE +AS $$ + SELECT nullif(current_setting('app.member_id', true), '')::uuid +$$; + +CREATE OR REPLACE FUNCTION public.app_is_admin() +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT coalesce(lower(nullif(current_setting('app.is_admin', true), '')) IN ('1', 'true', 't', 'yes', 'on'), false) +$$; + +CREATE OR REPLACE FUNCTION public.app_is_worker() +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT current_user = 'housebot_worker' + OR coalesce(lower(nullif(current_setting('app.is_worker', true), '')) IN ('1', 'true', 't', 'yes', 'on'), false) +$$; + +GRANT EXECUTE ON FUNCTION public.app_current_telegram_user_id() TO housebot_app, housebot_worker; +GRANT EXECUTE ON FUNCTION public.app_current_household_id() TO housebot_app, housebot_worker; +GRANT EXECUTE ON FUNCTION public.app_current_member_id() TO housebot_app, housebot_worker; +GRANT EXECUTE ON FUNCTION public.app_is_admin() TO housebot_app, housebot_worker; +GRANT EXECUTE ON FUNCTION public.app_is_worker() TO housebot_app, housebot_worker; + +DO $$ +DECLARE + table_name text; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'households', + 'household_billing_settings', + 'household_utility_categories', + 'household_telegram_chats', + 'household_topic_bindings', + 'household_join_tokens', + 'household_pending_members', + 'telegram_pending_actions', + 'members', + 'member_absence_policies', + 'billing_cycles', + 'rent_rules', + 'billing_cycle_exchange_rates', + 'utility_bills', + 'presence_overrides', + 'purchase_entries', + 'purchase_messages', + 'purchase_message_participants', + 'processed_bot_messages', + 'topic_messages', + 'anonymous_messages', + 'payment_confirmations', + 'payment_records', + 'settlements', + 'settlement_lines' + ] + LOOP + EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', table_name); + EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', table_name); + END LOOP; +END +$$; + +DROP POLICY IF EXISTS households_select ON public.households; +CREATE POLICY households_select +ON public.households +FOR SELECT +USING ( + public.app_is_worker() + OR public.app_current_telegram_user_id() IS NOT NULL + OR id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS households_insert ON public.households; +CREATE POLICY households_insert +ON public.households +FOR INSERT +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS households_update ON public.households; +CREATE POLICY households_update +ON public.households +FOR UPDATE +USING ( + public.app_is_worker() + OR (id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS households_delete ON public.households; +CREATE POLICY households_delete +ON public.households +FOR DELETE +USING (public.app_is_worker()); + +DROP POLICY IF EXISTS household_billing_settings_select ON public.household_billing_settings; +CREATE POLICY household_billing_settings_select +ON public.household_billing_settings +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_billing_settings_insert ON public.household_billing_settings; +CREATE POLICY household_billing_settings_insert +ON public.household_billing_settings +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_billing_settings_update ON public.household_billing_settings; +CREATE POLICY household_billing_settings_update +ON public.household_billing_settings +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_billing_settings_delete ON public.household_billing_settings; +CREATE POLICY household_billing_settings_delete +ON public.household_billing_settings +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_utility_categories_select ON public.household_utility_categories; +CREATE POLICY household_utility_categories_select +ON public.household_utility_categories +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_utility_categories_insert ON public.household_utility_categories; +CREATE POLICY household_utility_categories_insert +ON public.household_utility_categories +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_utility_categories_update ON public.household_utility_categories; +CREATE POLICY household_utility_categories_update +ON public.household_utility_categories +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_utility_categories_delete ON public.household_utility_categories; +CREATE POLICY household_utility_categories_delete +ON public.household_utility_categories +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_telegram_chats_select ON public.household_telegram_chats; +CREATE POLICY household_telegram_chats_select +ON public.household_telegram_chats +FOR SELECT +USING ( + public.app_is_worker() + OR public.app_current_telegram_user_id() IS NOT NULL + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_telegram_chats_insert ON public.household_telegram_chats; +CREATE POLICY household_telegram_chats_insert +ON public.household_telegram_chats +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_telegram_chats_update ON public.household_telegram_chats; +CREATE POLICY household_telegram_chats_update +ON public.household_telegram_chats +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_telegram_chats_delete ON public.household_telegram_chats; +CREATE POLICY household_telegram_chats_delete +ON public.household_telegram_chats +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_topic_bindings_select ON public.household_topic_bindings; +CREATE POLICY household_topic_bindings_select +ON public.household_topic_bindings +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_topic_bindings_insert ON public.household_topic_bindings; +CREATE POLICY household_topic_bindings_insert +ON public.household_topic_bindings +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_topic_bindings_update ON public.household_topic_bindings; +CREATE POLICY household_topic_bindings_update +ON public.household_topic_bindings +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_topic_bindings_delete ON public.household_topic_bindings; +CREATE POLICY household_topic_bindings_delete +ON public.household_topic_bindings +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_join_tokens_select ON public.household_join_tokens; +CREATE POLICY household_join_tokens_select +ON public.household_join_tokens +FOR SELECT +USING ( + public.app_is_worker() + OR public.app_current_telegram_user_id() IS NOT NULL + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_join_tokens_insert ON public.household_join_tokens; +CREATE POLICY household_join_tokens_insert +ON public.household_join_tokens +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_join_tokens_update ON public.household_join_tokens; +CREATE POLICY household_join_tokens_update +ON public.household_join_tokens +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_join_tokens_delete ON public.household_join_tokens; +CREATE POLICY household_join_tokens_delete +ON public.household_join_tokens +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_pending_members_select ON public.household_pending_members; +CREATE POLICY household_pending_members_select +ON public.household_pending_members +FOR SELECT +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS household_pending_members_insert ON public.household_pending_members; +CREATE POLICY household_pending_members_insert +ON public.household_pending_members +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_pending_members_update ON public.household_pending_members; +CREATE POLICY household_pending_members_update +ON public.household_pending_members +FOR UPDATE +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS household_pending_members_delete ON public.household_pending_members; +CREATE POLICY household_pending_members_delete +ON public.household_pending_members +FOR DELETE +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS telegram_pending_actions_select ON public.telegram_pending_actions; +CREATE POLICY telegram_pending_actions_select +ON public.telegram_pending_actions +FOR SELECT +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() +); + +DROP POLICY IF EXISTS telegram_pending_actions_insert ON public.telegram_pending_actions; +CREATE POLICY telegram_pending_actions_insert +ON public.telegram_pending_actions +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() +); + +DROP POLICY IF EXISTS telegram_pending_actions_update ON public.telegram_pending_actions; +CREATE POLICY telegram_pending_actions_update +ON public.telegram_pending_actions +FOR UPDATE +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() +) +WITH CHECK ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() +); + +DROP POLICY IF EXISTS telegram_pending_actions_delete ON public.telegram_pending_actions; +CREATE POLICY telegram_pending_actions_delete +ON public.telegram_pending_actions +FOR DELETE +USING ( + public.app_is_worker() + OR telegram_user_id = public.app_current_telegram_user_id() +); + +DROP POLICY IF EXISTS members_select ON public.members; +CREATE POLICY members_select +ON public.members +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() + OR telegram_user_id = public.app_current_telegram_user_id() +); + +DROP POLICY IF EXISTS members_insert ON public.members; +CREATE POLICY members_insert +ON public.members +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS members_update ON public.members; +CREATE POLICY members_update +ON public.members +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) + OR ( + household_id = public.app_current_household_id() + AND telegram_user_id = public.app_current_telegram_user_id() + ) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) + OR ( + household_id = public.app_current_household_id() + AND telegram_user_id = public.app_current_telegram_user_id() + ) +); + +DROP POLICY IF EXISTS members_delete ON public.members; +CREATE POLICY members_delete +ON public.members +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS member_absence_policies_select ON public.member_absence_policies; +CREATE POLICY member_absence_policies_select +ON public.member_absence_policies +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS member_absence_policies_insert ON public.member_absence_policies; +CREATE POLICY member_absence_policies_insert +ON public.member_absence_policies +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS member_absence_policies_update ON public.member_absence_policies; +CREATE POLICY member_absence_policies_update +ON public.member_absence_policies +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS member_absence_policies_delete ON public.member_absence_policies; +CREATE POLICY member_absence_policies_delete +ON public.member_absence_policies +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS billing_cycles_select ON public.billing_cycles; +CREATE POLICY billing_cycles_select +ON public.billing_cycles +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS billing_cycles_insert ON public.billing_cycles; +CREATE POLICY billing_cycles_insert +ON public.billing_cycles +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS billing_cycles_update ON public.billing_cycles; +CREATE POLICY billing_cycles_update +ON public.billing_cycles +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS billing_cycles_delete ON public.billing_cycles; +CREATE POLICY billing_cycles_delete +ON public.billing_cycles +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS rent_rules_select ON public.rent_rules; +CREATE POLICY rent_rules_select +ON public.rent_rules +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS rent_rules_insert ON public.rent_rules; +CREATE POLICY rent_rules_insert +ON public.rent_rules +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS rent_rules_update ON public.rent_rules; +CREATE POLICY rent_rules_update +ON public.rent_rules +FOR UPDATE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +) +WITH CHECK ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS rent_rules_delete ON public.rent_rules; +CREATE POLICY rent_rules_delete +ON public.rent_rules +FOR DELETE +USING ( + public.app_is_worker() + OR (household_id = public.app_current_household_id() AND public.app_is_admin()) +); + +DROP POLICY IF EXISTS billing_cycle_exchange_rates_select ON public.billing_cycle_exchange_rates; +CREATE POLICY billing_cycle_exchange_rates_select +ON public.billing_cycle_exchange_rates +FOR SELECT +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.billing_cycles cycle + WHERE cycle.id = billing_cycle_exchange_rates.cycle_id + AND cycle.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS billing_cycle_exchange_rates_insert ON public.billing_cycle_exchange_rates; +CREATE POLICY billing_cycle_exchange_rates_insert +ON public.billing_cycle_exchange_rates +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.billing_cycles cycle + WHERE cycle.id = billing_cycle_exchange_rates.cycle_id + AND cycle.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS billing_cycle_exchange_rates_update ON public.billing_cycle_exchange_rates; +CREATE POLICY billing_cycle_exchange_rates_update +ON public.billing_cycle_exchange_rates +FOR UPDATE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.billing_cycles cycle + WHERE cycle.id = billing_cycle_exchange_rates.cycle_id + AND cycle.household_id = public.app_current_household_id() + ) +) +WITH CHECK ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.billing_cycles cycle + WHERE cycle.id = billing_cycle_exchange_rates.cycle_id + AND cycle.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS billing_cycle_exchange_rates_delete ON public.billing_cycle_exchange_rates; +CREATE POLICY billing_cycle_exchange_rates_delete +ON public.billing_cycle_exchange_rates +FOR DELETE +USING ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.billing_cycles cycle + WHERE cycle.id = billing_cycle_exchange_rates.cycle_id + AND cycle.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS utility_bills_select ON public.utility_bills; +CREATE POLICY utility_bills_select +ON public.utility_bills +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS utility_bills_insert ON public.utility_bills; +CREATE POLICY utility_bills_insert +ON public.utility_bills +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS utility_bills_update ON public.utility_bills; +CREATE POLICY utility_bills_update +ON public.utility_bills +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS utility_bills_delete ON public.utility_bills; +CREATE POLICY utility_bills_delete +ON public.utility_bills +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS presence_overrides_select ON public.presence_overrides; +CREATE POLICY presence_overrides_select +ON public.presence_overrides +FOR SELECT +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.members member + WHERE member.id = presence_overrides.member_id + AND member.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS presence_overrides_insert ON public.presence_overrides; +CREATE POLICY presence_overrides_insert +ON public.presence_overrides +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.members member + WHERE member.id = presence_overrides.member_id + AND member.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS presence_overrides_update ON public.presence_overrides; +CREATE POLICY presence_overrides_update +ON public.presence_overrides +FOR UPDATE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.members member + WHERE member.id = presence_overrides.member_id + AND member.household_id = public.app_current_household_id() + ) +) +WITH CHECK ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.members member + WHERE member.id = presence_overrides.member_id + AND member.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS presence_overrides_delete ON public.presence_overrides; +CREATE POLICY presence_overrides_delete +ON public.presence_overrides +FOR DELETE +USING ( + public.app_is_worker() + OR ( + public.app_is_admin() + AND EXISTS ( + SELECT 1 + FROM public.members member + WHERE member.id = presence_overrides.member_id + AND member.household_id = public.app_current_household_id() + ) + ) +); + +DROP POLICY IF EXISTS purchase_entries_select ON public.purchase_entries; +CREATE POLICY purchase_entries_select +ON public.purchase_entries +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_entries_insert ON public.purchase_entries; +CREATE POLICY purchase_entries_insert +ON public.purchase_entries +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_entries_update ON public.purchase_entries; +CREATE POLICY purchase_entries_update +ON public.purchase_entries +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_entries_delete ON public.purchase_entries; +CREATE POLICY purchase_entries_delete +ON public.purchase_entries +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_messages_select ON public.purchase_messages; +CREATE POLICY purchase_messages_select +ON public.purchase_messages +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_messages_insert ON public.purchase_messages; +CREATE POLICY purchase_messages_insert +ON public.purchase_messages +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_messages_update ON public.purchase_messages; +CREATE POLICY purchase_messages_update +ON public.purchase_messages +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_messages_delete ON public.purchase_messages; +CREATE POLICY purchase_messages_delete +ON public.purchase_messages +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS purchase_message_participants_select ON public.purchase_message_participants; +CREATE POLICY purchase_message_participants_select +ON public.purchase_message_participants +FOR SELECT +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.purchase_messages purchase + WHERE purchase.id = purchase_message_participants.purchase_message_id + AND purchase.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS purchase_message_participants_insert ON public.purchase_message_participants; +CREATE POLICY purchase_message_participants_insert +ON public.purchase_message_participants +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.purchase_messages purchase + WHERE purchase.id = purchase_message_participants.purchase_message_id + AND purchase.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS purchase_message_participants_update ON public.purchase_message_participants; +CREATE POLICY purchase_message_participants_update +ON public.purchase_message_participants +FOR UPDATE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.purchase_messages purchase + WHERE purchase.id = purchase_message_participants.purchase_message_id + AND purchase.household_id = public.app_current_household_id() + ) +) +WITH CHECK ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.purchase_messages purchase + WHERE purchase.id = purchase_message_participants.purchase_message_id + AND purchase.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS purchase_message_participants_delete ON public.purchase_message_participants; +CREATE POLICY purchase_message_participants_delete +ON public.purchase_message_participants +FOR DELETE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.purchase_messages purchase + WHERE purchase.id = purchase_message_participants.purchase_message_id + AND purchase.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS processed_bot_messages_select ON public.processed_bot_messages; +CREATE POLICY processed_bot_messages_select +ON public.processed_bot_messages +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS processed_bot_messages_insert ON public.processed_bot_messages; +CREATE POLICY processed_bot_messages_insert +ON public.processed_bot_messages +FOR INSERT +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS processed_bot_messages_update ON public.processed_bot_messages; +CREATE POLICY processed_bot_messages_update +ON public.processed_bot_messages +FOR UPDATE +USING (public.app_is_worker()) +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS processed_bot_messages_delete ON public.processed_bot_messages; +CREATE POLICY processed_bot_messages_delete +ON public.processed_bot_messages +FOR DELETE +USING (public.app_is_worker()); + +DROP POLICY IF EXISTS topic_messages_select ON public.topic_messages; +CREATE POLICY topic_messages_select +ON public.topic_messages +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS topic_messages_insert ON public.topic_messages; +CREATE POLICY topic_messages_insert +ON public.topic_messages +FOR INSERT +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS topic_messages_update ON public.topic_messages; +CREATE POLICY topic_messages_update +ON public.topic_messages +FOR UPDATE +USING (public.app_is_worker()) +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS topic_messages_delete ON public.topic_messages; +CREATE POLICY topic_messages_delete +ON public.topic_messages +FOR DELETE +USING (public.app_is_worker()); + +DROP POLICY IF EXISTS anonymous_messages_select ON public.anonymous_messages; +CREATE POLICY anonymous_messages_select +ON public.anonymous_messages +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS anonymous_messages_insert ON public.anonymous_messages; +CREATE POLICY anonymous_messages_insert +ON public.anonymous_messages +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS anonymous_messages_update ON public.anonymous_messages; +CREATE POLICY anonymous_messages_update +ON public.anonymous_messages +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS anonymous_messages_delete ON public.anonymous_messages; +CREATE POLICY anonymous_messages_delete +ON public.anonymous_messages +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS payment_confirmations_select ON public.payment_confirmations; +CREATE POLICY payment_confirmations_select +ON public.payment_confirmations +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS payment_confirmations_insert ON public.payment_confirmations; +CREATE POLICY payment_confirmations_insert +ON public.payment_confirmations +FOR INSERT +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS payment_confirmations_update ON public.payment_confirmations; +CREATE POLICY payment_confirmations_update +ON public.payment_confirmations +FOR UPDATE +USING (public.app_is_worker()) +WITH CHECK (public.app_is_worker()); + +DROP POLICY IF EXISTS payment_confirmations_delete ON public.payment_confirmations; +CREATE POLICY payment_confirmations_delete +ON public.payment_confirmations +FOR DELETE +USING (public.app_is_worker()); + +DROP POLICY IF EXISTS payment_records_select ON public.payment_records; +CREATE POLICY payment_records_select +ON public.payment_records +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS payment_records_insert ON public.payment_records; +CREATE POLICY payment_records_insert +ON public.payment_records +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS payment_records_update ON public.payment_records; +CREATE POLICY payment_records_update +ON public.payment_records +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS payment_records_delete ON public.payment_records; +CREATE POLICY payment_records_delete +ON public.payment_records +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS settlements_select ON public.settlements; +CREATE POLICY settlements_select +ON public.settlements +FOR SELECT +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS settlements_insert ON public.settlements; +CREATE POLICY settlements_insert +ON public.settlements +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS settlements_update ON public.settlements; +CREATE POLICY settlements_update +ON public.settlements +FOR UPDATE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +) +WITH CHECK ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS settlements_delete ON public.settlements; +CREATE POLICY settlements_delete +ON public.settlements +FOR DELETE +USING ( + public.app_is_worker() + OR household_id = public.app_current_household_id() +); + +DROP POLICY IF EXISTS settlement_lines_select ON public.settlement_lines; +CREATE POLICY settlement_lines_select +ON public.settlement_lines +FOR SELECT +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.settlements settlement + WHERE settlement.id = settlement_lines.settlement_id + AND settlement.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS settlement_lines_insert ON public.settlement_lines; +CREATE POLICY settlement_lines_insert +ON public.settlement_lines +FOR INSERT +WITH CHECK ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.settlements settlement + WHERE settlement.id = settlement_lines.settlement_id + AND settlement.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS settlement_lines_update ON public.settlement_lines; +CREATE POLICY settlement_lines_update +ON public.settlement_lines +FOR UPDATE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.settlements settlement + WHERE settlement.id = settlement_lines.settlement_id + AND settlement.household_id = public.app_current_household_id() + ) +) +WITH CHECK ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.settlements settlement + WHERE settlement.id = settlement_lines.settlement_id + AND settlement.household_id = public.app_current_household_id() + ) +); + +DROP POLICY IF EXISTS settlement_lines_delete ON public.settlement_lines; +CREATE POLICY settlement_lines_delete +ON public.settlement_lines +FOR DELETE +USING ( + public.app_is_worker() + OR EXISTS ( + SELECT 1 + FROM public.settlements settlement + WHERE settlement.id = settlement_lines.settlement_id + AND settlement.household_id = public.app_current_household_id() + ) +); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 5bf2f3d..2864576 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1774200000000, "tag": "0021_sharp_payer", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1774204831000, + "tag": "0022_harden_rls", + "breakpoints": true } ] } diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index e11e05f..bb4c181 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,9 +1,34 @@ import postgres from 'postgres' import { drizzle } from 'drizzle-orm/postgres-js' +export interface DbSessionContext { + telegramUserId?: string + householdId?: string + memberId?: string + isAdmin?: boolean + isWorker?: boolean +} + export interface DbClientOptions { max?: number prepare?: boolean + sessionContext?: DbSessionContext +} + +function quoteRuntimeOptionValue(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` +} + +function appendRuntimeOption( + options: string[], + key: string, + value: string | boolean | undefined +): void { + if (value === undefined) { + return + } + + options.push(`-c ${key}=${quoteRuntimeOptionValue(String(value))}`) } export function createDbClient(databaseUrl: string, options: DbClientOptions = {}) { @@ -17,7 +42,17 @@ export function createDbClient(databaseUrl: string, options: DbClientOptions = { url.searchParams.delete('options') // Set search_path via options parameter (required for PgBouncer compatibility) - url.searchParams.set('options', `-c search_path=${dbSchema}`) + const runtimeOptions = [`-c search_path=${dbSchema}`] + appendRuntimeOption( + runtimeOptions, + 'app.telegram_user_id', + options.sessionContext?.telegramUserId + ) + appendRuntimeOption(runtimeOptions, 'app.household_id', options.sessionContext?.householdId) + appendRuntimeOption(runtimeOptions, 'app.member_id', options.sessionContext?.memberId) + appendRuntimeOption(runtimeOptions, 'app.is_admin', options.sessionContext?.isAdmin) + appendRuntimeOption(runtimeOptions, 'app.is_worker', options.sessionContext?.isWorker) + url.searchParams.set('options', runtimeOptions.join(' ')) const cleanUrl = url.toString() diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 825229d..0f1d197 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,2 +1,3 @@ export { createDbClient } from './client' +export type { DbSessionContext } from './client' export * as schema from './schema'