diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index 3a7e07b..f399042 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -9,6 +9,8 @@ export interface BotRuntimeConfig { telegramPurchaseTopicId?: number purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean + miniAppAllowedOrigins: readonly string[] + miniAppAuthEnabled: boolean schedulerSharedSecret?: string schedulerOidcAllowedEmails: readonly string[] reminderJobsEnabled: boolean @@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS) + const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS) const purchaseTopicIngestionEnabled = databaseUrl !== undefined && @@ -83,6 +86,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramPurchaseTopicId !== undefined const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined + const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = databaseUrl !== undefined && @@ -96,6 +100,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram', purchaseTopicIngestionEnabled, financeCommandsEnabled, + miniAppAllowedOrigins, + miniAppAuthEnabled, schedulerOidcAllowedEmails, reminderJobsEnabled, parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini' diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 09dee1b..a05fd67 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -17,12 +17,21 @@ import { import { createReminderJobsHandler } from './reminder-jobs' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' +import { createMiniAppAuthHandler } from './miniapp-auth' const runtime = getBotRuntimeConfig() const bot = createTelegramBot(runtime.telegramBotToken) const webhookHandler = webhookCallback(bot, 'std/http') const shutdownTasks: Array<() => Promise> = [] +const financeRepositoryClient = + runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled + ? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!) + : null + +if (financeRepositoryClient) { + shutdownTasks.push(financeRepositoryClient.close) +} if (runtime.purchaseTopicIngestionEnabled) { const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) @@ -50,15 +59,10 @@ if (runtime.purchaseTopicIngestionEnabled) { } if (runtime.financeCommandsEnabled) { - const financeRepositoryClient = createDbFinanceRepository( - runtime.databaseUrl!, - runtime.householdId! - ) - const financeService = createFinanceCommandService(financeRepositoryClient.repository) + const financeService = createFinanceCommandService(financeRepositoryClient!.repository) const financeCommands = createFinanceCommandsService(financeService) financeCommands.register(bot) - shutdownTasks.push(financeRepositoryClient.close) } else { console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.') } @@ -87,6 +91,13 @@ const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, webhookHandler, + miniAppAuth: financeRepositoryClient + ? createMiniAppAuthHandler({ + allowedOrigins: runtime.miniAppAllowedOrigins, + botToken: runtime.telegramBotToken, + repository: financeRepositoryClient.repository + }) + : undefined, scheduler: reminderJobs && runtime.schedulerSharedSecret ? { diff --git a/apps/bot/src/miniapp-auth.test.ts b/apps/bot/src/miniapp-auth.test.ts new file mode 100644 index 0000000..adc669a --- /dev/null +++ b/apps/bot/src/miniapp-auth.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import type { FinanceRepository } from '@household/ports' + +import { createMiniAppAuthHandler } from './miniapp-auth' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +function repository( + member: Awaited> +): FinanceRepository { + return { + getMemberByTelegramUserId: async () => member, + listMembers: async () => [], + getOpenCycle: async () => null, + getCycleByPeriod: async () => null, + getLatestCycle: async () => null, + openCycle: async () => {}, + closeCycle: async () => {}, + saveRentRule: async () => {}, + addUtilityBill: async () => {}, + getRentRuleForPeriod: async () => null, + getUtilityTotalForCycle: async () => 0n, + listParsedPurchasesForRange: async () => [], + replaceSettlementSnapshot: async () => {} + } +} + +describe('createMiniAppAuthHandler', () => { + test('returns an authorized session for a household member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const auth = createMiniAppAuthHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + repository: repository({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + isAdmin: true + }) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan', + username: 'stanislav', + language_code: 'ru' + }) + }) + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:5173') + expect(await response.json()).toMatchObject({ + ok: true, + authorized: true, + member: { + displayName: 'Stan', + isAdmin: true + }, + telegramUser: { + id: '123456', + firstName: 'Stan', + username: 'stanislav', + languageCode: 'ru' + } + }) + }) + + test('returns membership gate failure for a non-member', async () => { + const authDate = Math.floor(Date.now() / 1000) + const auth = createMiniAppAuthHandler({ + allowedOrigins: ['http://localhost:5173'], + botToken: 'test-bot-token', + repository: repository(null) + }) + + const response = await auth.handler( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + headers: { + origin: 'http://localhost:5173', + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData: buildInitData('test-bot-token', authDate, { + id: 123456, + first_name: 'Stan' + }) + }) + }) + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + ok: true, + authorized: false, + reason: 'not_member' + }) + }) +}) diff --git a/apps/bot/src/miniapp-auth.ts b/apps/bot/src/miniapp-auth.ts new file mode 100644 index 0000000..035d701 --- /dev/null +++ b/apps/bot/src/miniapp-auth.ts @@ -0,0 +1,117 @@ +import type { FinanceRepository } from '@household/ports' + +import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' + +function json(body: object, status = 200, origin?: string): Response { + const headers = new Headers({ + 'content-type': 'application/json; charset=utf-8' + }) + + if (origin) { + headers.set('access-control-allow-origin', origin) + headers.set('access-control-allow-methods', 'POST, OPTIONS') + headers.set('access-control-allow-headers', 'content-type') + headers.set('vary', 'origin') + } + + return new Response(JSON.stringify(body), { + status, + headers + }) +} + +function allowedOrigin(request: Request, allowedOrigins: readonly string[]): string | undefined { + const origin = request.headers.get('origin') + + if (!origin) { + return undefined + } + + if (allowedOrigins.length === 0) { + return origin + } + + return allowedOrigins.includes(origin) ? origin : undefined +} + +async function readInitData(request: Request): Promise { + const text = await request.text() + + if (text.trim().length === 0) { + return null + } + + const parsed = JSON.parse(text) as { initData?: string } + const initData = parsed.initData?.trim() + + return initData && initData.length > 0 ? initData : null +} + +export function createMiniAppAuthHandler(options: { + allowedOrigins: readonly string[] + botToken: string + repository: FinanceRepository +}): { + handler: (request: Request) => Promise +} { + return { + handler: async (request) => { + const origin = allowedOrigin(request, options.allowedOrigins) + + if (request.method === 'OPTIONS') { + return json({ ok: true }, 204, origin) + } + + if (request.method !== 'POST') { + return json({ ok: false, error: 'Method Not Allowed' }, 405, origin) + } + + try { + const initData = await readInitData(request) + if (!initData) { + return json({ ok: false, error: 'Missing initData' }, 400, origin) + } + + const telegramUser = verifyTelegramMiniAppInitData(initData, options.botToken) + if (!telegramUser) { + return json({ ok: false, error: 'Invalid Telegram init data' }, 401, origin) + } + + const member = await options.repository.getMemberByTelegramUserId(telegramUser.id) + if (!member) { + return json( + { + ok: true, + authorized: false, + reason: 'not_member' + }, + 403, + origin + ) + } + + return json( + { + ok: true, + authorized: true, + member: { + id: member.id, + displayName: member.displayName, + isAdmin: member.isAdmin + }, + telegramUser, + features: { + balances: false, + ledger: false + } + }, + 200, + origin + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown mini app auth error' + return json({ ok: false, error: message }, 400, origin) + } + } + } +} diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index b747349..83a2ab2 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -7,6 +7,15 @@ describe('createBotWebhookServer', () => { webhookPath: '/webhook/telegram', webhookSecret: 'secret-token', webhookHandler: async () => new Response('ok', { status: 200 }), + miniAppAuth: { + handler: async () => + new Response(JSON.stringify({ ok: true, authorized: true }), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }) + }, scheduler: { authorize: async (request) => request.headers.get('x-household-scheduler-secret') === 'scheduler-secret', @@ -71,6 +80,21 @@ describe('createBotWebhookServer', () => { expect(await response.text()).toBe('ok') }) + test('accepts mini app auth request', async () => { + const response = await server.fetch( + new Request('http://localhost/api/miniapp/session', { + method: 'POST', + body: JSON.stringify({ initData: 'payload' }) + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + authorized: true + }) + }) + test('rejects scheduler request with missing secret', async () => { const response = await server.fetch( new Request('http://localhost/jobs/reminder/utilities', { diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts index d8c3f9d..171feaa 100644 --- a/apps/bot/src/server.ts +++ b/apps/bot/src/server.ts @@ -2,6 +2,12 @@ export interface BotWebhookServerOptions { webhookPath: string webhookSecret: string webhookHandler: (request: Request) => Promise | Response + miniAppAuth?: + | { + path?: string + handler: (request: Request) => Promise + } + | undefined scheduler?: | { pathPrefix?: string @@ -32,6 +38,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { const normalizedWebhookPath = options.webhookPath.startsWith('/') ? options.webhookPath : `/${options.webhookPath}` + const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session' const schedulerPathPrefix = options.scheduler ? (options.scheduler.pathPrefix ?? '/jobs/reminder') : null @@ -44,6 +51,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): { return json({ ok: true }) } + if (options.miniAppAuth && url.pathname === miniAppAuthPath) { + return await options.miniAppAuth.handler(request) + } + if (url.pathname !== normalizedWebhookPath) { if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) { if (request.method !== 'POST') { diff --git a/apps/bot/src/telegram-miniapp-auth.test.ts b/apps/bot/src/telegram-miniapp-auth.test.ts new file mode 100644 index 0000000..45e53f1 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-auth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { createHmac } from 'node:crypto' + +import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth' + +function buildInitData(botToken: string, authDate: number, user: object): string { + const params = new URLSearchParams() + params.set('auth_date', authDate.toString()) + params.set('query_id', 'AAHdF6IQAAAAAN0XohDhrOrc') + params.set('user', JSON.stringify(user)) + + const dataCheckString = [...params.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('\n') + + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const hash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + params.set('hash', hash) + + return params.toString() +} + +describe('verifyTelegramMiniAppInitData', () => { + test('verifies valid init data and extracts user payload', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + id: 123456, + first_name: 'Stan', + username: 'stanislav' + }) + + const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now) + + expect(result).toEqual({ + id: '123456', + firstName: 'Stan', + lastName: null, + username: 'stanislav', + languageCode: null + }) + }) + + test('rejects invalid hash', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const params = new URLSearchParams( + buildInitData('test-bot-token', Math.floor(now.getTime() / 1000), { + id: 123456, + first_name: 'Stan' + }) + ) + params.set('hash', '0'.repeat(64)) + + const result = verifyTelegramMiniAppInitData(params.toString(), 'test-bot-token', now) + + expect(result).toBeNull() + }) + + test('rejects expired init data', () => { + const now = new Date('2026-03-08T12:00:00.000Z') + const initData = buildInitData('test-bot-token', Math.floor(now.getTime() / 1000) - 7200, { + id: 123456, + first_name: 'Stan' + }) + + const result = verifyTelegramMiniAppInitData(initData, 'test-bot-token', now, 3600) + + expect(result).toBeNull() + }) +}) diff --git a/apps/bot/src/telegram-miniapp-auth.ts b/apps/bot/src/telegram-miniapp-auth.ts new file mode 100644 index 0000000..fe543b9 --- /dev/null +++ b/apps/bot/src/telegram-miniapp-auth.ts @@ -0,0 +1,85 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +interface TelegramUserPayload { + id: number + first_name?: string + last_name?: string + username?: string + language_code?: string +} + +export interface VerifiedMiniAppUser { + id: string + firstName: string | null + lastName: string | null + username: string | null + languageCode: string | null +} + +export function verifyTelegramMiniAppInitData( + initData: string, + botToken: string, + now = new Date(), + maxAgeSeconds = 3600 +): VerifiedMiniAppUser | null { + const params = new URLSearchParams(initData) + const hash = params.get('hash') + + if (!hash) { + return null + } + + const authDateRaw = params.get('auth_date') + if (!authDateRaw || !/^\d+$/.test(authDateRaw)) { + return null + } + + const authDateSeconds = Number(authDateRaw) + const nowSeconds = Math.floor(now.getTime() / 1000) + if (Math.abs(nowSeconds - authDateSeconds) > maxAgeSeconds) { + return null + } + + const userRaw = params.get('user') + if (!userRaw) { + return null + } + + const payloadEntries = [...params.entries()] + .filter(([key]) => key !== 'hash') + .sort(([left], [right]) => left.localeCompare(right)) + + const dataCheckString = payloadEntries.map(([key, value]) => `${key}=${value}`).join('\n') + const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest() + const expectedHash = createHmac('sha256', secretKey).update(dataCheckString).digest('hex') + + const expectedBuffer = Buffer.from(expectedHash, 'hex') + const actualBuffer = Buffer.from(hash, 'hex') + + if (expectedBuffer.length !== actualBuffer.length) { + return null + } + + if (!timingSafeEqual(expectedBuffer, actualBuffer)) { + return null + } + + let parsedUser: TelegramUserPayload + try { + parsedUser = JSON.parse(userRaw) as TelegramUserPayload + } catch { + return null + } + + if (!Number.isInteger(parsedUser.id) || parsedUser.id <= 0) { + return null + } + + return { + id: parsedUser.id.toString(), + firstName: parsedUser.first_name?.trim() || null, + lastName: parsedUser.last_name?.trim() || null, + username: parsedUser.username?.trim() || null, + languageCode: parsedUser.language_code?.trim() || null + } +} diff --git a/apps/miniapp/Dockerfile b/apps/miniapp/Dockerfile index b6a79f7..3590bca 100644 --- a/apps/miniapp/Dockerfile +++ b/apps/miniapp/Dockerfile @@ -26,10 +26,15 @@ RUN bun run --filter @household/miniapp build FROM nginx:1.27-alpine AS runtime +ENV BOT_API_URL="" + COPY apps/miniapp/nginx.conf /etc/nginx/conf.d/default.conf +COPY apps/miniapp/config.template.js /usr/share/nginx/html/config.template.js COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1 + +CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/config.template.js > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"] diff --git a/apps/miniapp/config.template.js b/apps/miniapp/config.template.js new file mode 100644 index 0000000..7e7a477 --- /dev/null +++ b/apps/miniapp/config.template.js @@ -0,0 +1,3 @@ +window.__HOUSEHOLD_CONFIG__ = { + botApiUrl: '${BOT_API_URL}' +} diff --git a/apps/miniapp/index.html b/apps/miniapp/index.html index 5ede123..decebfd 100644 --- a/apps/miniapp/index.html +++ b/apps/miniapp/index.html @@ -3,13 +3,14 @@ - - Solid App + + Kojori House
+ diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 46a7d2f..3eeb8e1 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -1,8 +1,255 @@ +import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js' + +import { dictionary, type Locale } from './i18n' +import { fetchMiniAppSession } from './miniapp-api' +import { getTelegramWebApp } from './telegram-webapp' + +type SessionState = + | { + status: 'loading' + } + | { + status: 'blocked' + reason: 'not_member' | 'telegram_only' | 'error' + } + | { + status: 'ready' + mode: 'live' | 'demo' + member: { + displayName: string + isAdmin: boolean + } + telegramUser: { + firstName: string | null + username: string | null + languageCode: string | null + } + } + +type NavigationKey = 'home' | 'balances' | 'ledger' | 'house' + +const demoSession: Extract = { + status: 'ready', + mode: 'demo', + member: { + displayName: 'Demo Resident', + isAdmin: false + }, + telegramUser: { + firstName: 'Demo', + username: 'demo_user', + languageCode: 'en' + } +} + +function detectLocale(): Locale { + const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code + const browserLocale = navigator.language.toLowerCase() + + return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en' +} + function App() { + const [locale, setLocale] = createSignal('en') + const [session, setSession] = createSignal({ + status: 'loading' + }) + const [activeNav, setActiveNav] = createSignal('home') + + const copy = createMemo(() => dictionary[locale()]) + const blockedSession = createMemo(() => { + const current = session() + return current.status === 'blocked' ? current : null + }) + const readySession = createMemo(() => { + const current = session() + return current.status === 'ready' ? current : null + }) + const webApp = getTelegramWebApp() + + onMount(async () => { + setLocale(detectLocale()) + + webApp?.ready?.() + webApp?.expand?.() + + const initData = webApp?.initData?.trim() + if (!initData) { + if (import.meta.env.DEV) { + setSession(demoSession) + return + } + + setSession({ + status: 'blocked', + reason: 'telegram_only' + }) + return + } + + try { + const payload = await fetchMiniAppSession(initData) + if (!payload.authorized || !payload.member || !payload.telegramUser) { + setSession({ + status: 'blocked', + reason: payload.reason === 'not_member' ? 'not_member' : 'error' + }) + return + } + + setSession({ + status: 'ready', + mode: 'live', + member: payload.member, + telegramUser: payload.telegramUser + }) + } catch { + if (import.meta.env.DEV) { + setSession(demoSession) + return + } + + setSession({ + status: 'blocked', + reason: 'error' + }) + } + }) + + const renderPanel = () => { + switch (activeNav()) { + case 'balances': + return copy().balancesEmpty + case 'ledger': + return copy().ledgerEmpty + case 'house': + return copy().houseEmpty + default: + return copy().summaryBody + } + } + return ( -
-

Household Mini App

-

SolidJS scaffold is ready

+
+
+
+ +
+
+

{copy().appSubtitle}

+

{copy().appTitle}

+
+ + +
+ + + +
+ {copy().navHint} +

{copy().loadingTitle}

+

{copy().loadingBody}

+
+
+ + +
+ {copy().navHint} +

+ {blockedSession()?.reason === 'telegram_only' + ? copy().telegramOnlyTitle + : copy().unauthorizedTitle} +

+

+ {blockedSession()?.reason === 'telegram_only' + ? copy().telegramOnlyBody + : copy().unauthorizedBody} +

+ +
+
+ + +
+
+ + {readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint} + + + {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} + +
+ +

+ {copy().welcome},{' '} + {readySession()?.telegramUser.firstName ?? readySession()?.member.displayName} +

+

{copy().sectionBody}

+
+ + + +
+
+

{copy().summaryTitle}

+

{readySession()?.member.displayName}

+

{renderPanel()}

+
+ +
+

{copy().cardAccess}

+

{copy().cardAccessBody}

+
+ +
+

{copy().cardLocale}

+

{copy().cardLocaleBody}

+
+ +
+

{copy().cardNext}

+

{copy().cardNextBody}

+
+
+
+
) } diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts new file mode 100644 index 0000000..dc7a680 --- /dev/null +++ b/apps/miniapp/src/i18n.ts @@ -0,0 +1,80 @@ +export type Locale = 'en' | 'ru' + +export const dictionary = { + en: { + appTitle: 'Kojori House', + appSubtitle: 'Shared home dashboard', + loadingTitle: 'Checking your household access', + loadingBody: 'Validating Telegram session and membership…', + demoBadge: 'Demo mode', + unauthorizedTitle: 'Access is limited to active household members', + unauthorizedBody: + 'Open the mini app from Telegram after the bot admin adds you to the household.', + telegramOnlyTitle: 'Open this app from Telegram', + telegramOnlyBody: + 'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.', + reload: 'Retry', + language: 'Language', + home: 'Home', + balances: 'Balances', + ledger: 'Ledger', + house: 'House', + navHint: 'Shell v1', + welcome: 'Welcome back', + adminTag: 'Admin', + residentTag: 'Resident', + summaryTitle: 'Current shell', + summaryBody: + 'Balances, ledger, and house wiki will land in the next tickets. This shell focuses on verified access, navigation, and mobile layout.', + cardAccess: 'Access', + cardAccessBody: 'Telegram identity verified and matched to a household member.', + cardLocale: 'Locale', + cardLocaleBody: 'Switch RU/EN immediately without reloading the shell.', + cardNext: 'Next up', + cardNextBody: 'Balances, ledger, and house pages will plug into this navigation.', + sectionTitle: 'Ready for the next features', + sectionBody: + 'This layout is intentionally narrow and mobile-first so it behaves well inside the Telegram webview.', + balancesEmpty: 'Balances will appear here once the dashboard API lands.', + ledgerEmpty: 'Ledger entries will appear here after the finance view is connected.', + houseEmpty: 'House rules, Wi-Fi info, and practical notes will live here.' + }, + ru: { + appTitle: 'Kojori House', + appSubtitle: 'Панель общего дома', + loadingTitle: 'Проверяем доступ к дому', + loadingBody: 'Проверяем Telegram-сессию и членство…', + demoBadge: 'Демо режим', + unauthorizedTitle: 'Доступ открыт только для активных участников дома', + unauthorizedBody: + 'Открой мини-апп из Telegram после того, как админ бота добавит тебя в household.', + telegramOnlyTitle: 'Открой приложение из Telegram', + telegramOnlyBody: + 'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.', + reload: 'Повторить', + language: 'Язык', + home: 'Главная', + balances: 'Баланс', + ledger: 'Леджер', + house: 'Дом', + navHint: 'Shell v1', + welcome: 'С возвращением', + adminTag: 'Админ', + residentTag: 'Житель', + summaryTitle: 'Текущая оболочка', + summaryBody: + 'Баланс, леджер и вики дома появятся в следующих тикетах. Сейчас приоритет — проверенный доступ, навигация и мобильный layout.', + cardAccess: 'Доступ', + cardAccessBody: 'Telegram-личность подтверждена и сопоставлена с участником household.', + cardLocale: 'Локаль', + cardLocaleBody: 'RU/EN переключаются сразу, без перезагрузки.', + cardNext: 'Дальше', + cardNextBody: 'Баланс, леджер и страницы дома подключатся к этой навигации.', + sectionTitle: 'Основа готова для следующих функций', + sectionBody: + 'Этот layout специально сделан узким и mobile-first, чтобы хорошо жить внутри Telegram webview.', + balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.', + ledgerEmpty: 'Записи леджера появятся здесь после подключения finance view.', + houseEmpty: 'Правила дома, Wi-Fi и полезные инструкции будут здесь.' + } +} satisfies Record> diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index d4b5078..e5ebdb1 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -1 +1,231 @@ @import 'tailwindcss'; + +:root { + color: #f5efe1; + background: + radial-gradient(circle at top, rgb(225 116 58 / 0.32), transparent 32%), + radial-gradient(circle at bottom left, rgb(79 120 149 / 0.26), transparent 28%), + linear-gradient(180deg, #121a24 0%, #0b1118 100%); + font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: #f5efe1; + background: transparent; +} + +button { + font: inherit; +} + +#root { + min-height: 100vh; +} + +.shell { + position: relative; + min-height: 100vh; + overflow: hidden; + padding: 24px 18px 32px; +} + +.shell__backdrop { + position: absolute; + border-radius: 999px; + filter: blur(12px); + opacity: 0.85; +} + +.shell__backdrop--top { + top: -120px; + right: -60px; + width: 260px; + height: 260px; + background: rgb(237 131 74 / 0.3); +} + +.shell__backdrop--bottom { + bottom: -140px; + left: -80px; + width: 300px; + height: 300px; + background: rgb(87 129 159 / 0.22); +} + +.topbar, +.hero-card, +.nav-grid, +.content-grid { + position: relative; + z-index: 1; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.topbar h1, +.hero-card h2, +.panel h3 { + margin: 0; + font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif; + letter-spacing: -0.04em; +} + +.topbar h1 { + font-size: clamp(2rem, 5vw, 3rem); +} + +.eyebrow { + margin: 0 0 8px; + color: #f7b389; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.locale-switch { + display: grid; + gap: 8px; + min-width: 116px; + color: #d8d6cf; + font-size: 0.82rem; +} + +.locale-switch__buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.locale-switch__buttons button, +.nav-grid button, +.ghost-button { + border: 1px solid rgb(255 255 255 / 0.12); + background: rgb(255 255 255 / 0.04); + color: inherit; + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease; +} + +.locale-switch__buttons button { + border-radius: 999px; + padding: 10px 0; +} + +.locale-switch__buttons button.is-active, +.nav-grid button.is-active { + border-color: rgb(247 179 137 / 0.7); + background: rgb(247 179 137 / 0.14); +} + +.hero-card, +.panel { + border: 1px solid rgb(255 255 255 / 0.1); + background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02)); + backdrop-filter: blur(16px); + box-shadow: 0 24px 64px rgb(0 0 0 / 0.22); +} + +.hero-card { + margin-top: 28px; + border-radius: 28px; + padding: 22px; +} + +.hero-card__meta { + display: flex; + gap: 10px; + margin-bottom: 16px; +} + +.hero-card h2 { + font-size: clamp(1.5rem, 4vw, 2.4rem); + margin-bottom: 10px; +} + +.hero-card p, +.panel p { + margin: 0; + color: #d6d3cc; + line-height: 1.55; +} + +.pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + background: rgb(247 179 137 / 0.14); + color: #ffd5b7; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pill--muted { + background: rgb(255 255 255 / 0.08); + color: #e5e2d8; +} + +.ghost-button { + margin-top: 18px; + border-radius: 16px; + padding: 12px 16px; +} + +.nav-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.nav-grid button { + border-radius: 18px; + padding: 14px 8px; +} + +.content-grid { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.panel { + border-radius: 24px; + padding: 18px; +} + +.panel--wide { + min-height: 170px; +} + +@media (min-width: 760px) { + .shell { + max-width: 920px; + margin: 0 auto; + padding: 32px 24px 40px; + } + + .content-grid { + grid-template-columns: 1.3fr 1fr 1fr; + } + + .panel--wide { + grid-column: 1 / -1; + } +} diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts new file mode 100644 index 0000000..2773cab --- /dev/null +++ b/apps/miniapp/src/miniapp-api.ts @@ -0,0 +1,66 @@ +import { runtimeBotApiUrl } from './runtime-config' + +export interface MiniAppSession { + authorized: boolean + member?: { + displayName: string + isAdmin: boolean + } + telegramUser?: { + firstName: string | null + username: string | null + languageCode: string | null + } + reason?: string +} + +function apiBaseUrl(): string { + const runtimeConfigured = runtimeBotApiUrl() + if (runtimeConfigured) { + return runtimeConfigured.replace(/\/$/, '') + } + + const configured = import.meta.env.VITE_BOT_API_URL?.trim() + + if (configured) { + return configured.replace(/\/$/, '') + } + + if (import.meta.env.DEV) { + return 'http://localhost:3000' + } + + return window.location.origin +} + +export async function fetchMiniAppSession(initData: string): Promise { + const response = await fetch(`${apiBaseUrl()}/api/miniapp/session`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + initData + }) + }) + + const payload = (await response.json()) as { + ok: boolean + authorized?: boolean + member?: MiniAppSession['member'] + telegramUser?: MiniAppSession['telegramUser'] + reason?: string + error?: string + } + + if (!response.ok) { + throw new Error(payload.error) + } + + return { + authorized: payload.authorized === true, + ...(payload.member ? { member: payload.member } : {}), + ...(payload.telegramUser ? { telegramUser: payload.telegramUser } : {}), + ...(payload.reason ? { reason: payload.reason } : {}) + } +} diff --git a/apps/miniapp/src/runtime-config.ts b/apps/miniapp/src/runtime-config.ts new file mode 100644 index 0000000..a5b12c2 --- /dev/null +++ b/apps/miniapp/src/runtime-config.ts @@ -0,0 +1,13 @@ +declare global { + interface Window { + __HOUSEHOLD_CONFIG__?: { + botApiUrl?: string + } + } +} + +export function runtimeBotApiUrl(): string | undefined { + const configured = window.__HOUSEHOLD_CONFIG__?.botApiUrl?.trim() + + return configured && configured.length > 0 ? configured : undefined +} diff --git a/apps/miniapp/src/telegram-webapp.ts b/apps/miniapp/src/telegram-webapp.ts new file mode 100644 index 0000000..f581e7b --- /dev/null +++ b/apps/miniapp/src/telegram-webapp.ts @@ -0,0 +1,27 @@ +export interface TelegramWebAppUser { + id: number + first_name?: string + username?: string + language_code?: string +} + +export interface TelegramWebApp { + initData: string + initDataUnsafe?: { + user?: TelegramWebAppUser + } + ready?: () => void + expand?: () => void +} + +declare global { + interface Window { + Telegram?: { + WebApp?: TelegramWebApp + } + } +} + +export function getTelegramWebApp(): TelegramWebApp | undefined { + return window.Telegram?.WebApp +} diff --git a/apps/miniapp/src/vite-env.d.ts b/apps/miniapp/src/vite-env.d.ts new file mode 100644 index 0000000..7b83d49 --- /dev/null +++ b/apps/miniapp/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BOT_API_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/docs/specs/HOUSEBOT-040-miniapp-shell.md b/docs/specs/HOUSEBOT-040-miniapp-shell.md new file mode 100644 index 0000000..8e660ef --- /dev/null +++ b/docs/specs/HOUSEBOT-040-miniapp-shell.md @@ -0,0 +1,60 @@ +# HOUSEBOT-040: Mini App Shell with Telegram Auth Gate + +## Summary + +Build the first usable SolidJS mini app shell with a real Telegram initData verification flow and a household membership gate. + +## Goals + +- Verify Telegram mini app initData on the backend. +- Block non-members from entering the mini app shell. +- Provide a bilingual RU/EN shell with navigation ready for later dashboard features. +- Keep local development usable with a demo fallback. + +## Non-goals + +- Full balances and ledger data rendering. +- House wiki content population. +- Production analytics or full design-system work. + +## Scope + +- In: backend auth endpoint, membership lookup, CORS handling, shell layout, locale toggle, runtime bot API URL injection. +- Out: real balances API, ledger API, notification center. + +## Interfaces and Contracts + +- Backend endpoint: `POST /api/miniapp/session` +- Request body: + - `initData: string` +- Success response: + - `authorized: true` + - `member` + - `telegramUser` +- Membership failure: + - `authorized: false` + - `reason: "not_member"` + +## Security and Privacy + +- Telegram initData is verified with the bot token before membership lookup. +- Mini app access depends on an actual household membership match. +- CORS can be limited via `MINI_APP_ALLOWED_ORIGINS`; if unset, the endpoint falls back to permissive origin reflection for deployment simplicity. + +## UX Notes + +- RU/EN switch is always visible. +- Demo shell appears automatically in local development when Telegram data is unavailable. +- Layout is mobile-first and Telegram webview friendly. + +## Test Plan + +- Unit tests for Telegram initData verification. +- Unit tests for mini app auth handler membership outcomes. +- Full repo typecheck, tests, and build. + +## Acceptance Criteria + +- [ ] Unauthorized users are blocked. +- [ ] RU/EN language switch is present. +- [ ] Base shell and navigation are ready for later finance views. diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index be0eb94..5383f1c 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -140,7 +140,8 @@ module "mini_app_service" { labels = local.common_labels env = { - NODE_ENV = var.environment + NODE_ENV = var.environment + BOT_API_URL = module.bot_api_service.uri } depends_on = [google_project_service.enabled]