mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:54:02 +00:00
feat(miniapp): add telegram-authenticated shell
This commit is contained in:
@@ -9,6 +9,8 @@ export interface BotRuntimeConfig {
|
|||||||
telegramPurchaseTopicId?: number
|
telegramPurchaseTopicId?: number
|
||||||
purchaseTopicIngestionEnabled: boolean
|
purchaseTopicIngestionEnabled: boolean
|
||||||
financeCommandsEnabled: boolean
|
financeCommandsEnabled: boolean
|
||||||
|
miniAppAllowedOrigins: readonly string[]
|
||||||
|
miniAppAuthEnabled: boolean
|
||||||
schedulerSharedSecret?: string
|
schedulerSharedSecret?: string
|
||||||
schedulerOidcAllowedEmails: readonly string[]
|
schedulerOidcAllowedEmails: readonly string[]
|
||||||
reminderJobsEnabled: boolean
|
reminderJobsEnabled: boolean
|
||||||
@@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||||
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
||||||
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
||||||
|
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
||||||
|
|
||||||
const purchaseTopicIngestionEnabled =
|
const purchaseTopicIngestionEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined &&
|
||||||
@@ -83,6 +86,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramPurchaseTopicId !== undefined
|
telegramPurchaseTopicId !== undefined
|
||||||
|
|
||||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
|
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
databaseUrl !== undefined &&
|
databaseUrl !== undefined &&
|
||||||
@@ -96,6 +100,8 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||||
purchaseTopicIngestionEnabled,
|
purchaseTopicIngestionEnabled,
|
||||||
financeCommandsEnabled,
|
financeCommandsEnabled,
|
||||||
|
miniAppAllowedOrigins,
|
||||||
|
miniAppAuthEnabled,
|
||||||
schedulerOidcAllowedEmails,
|
schedulerOidcAllowedEmails,
|
||||||
reminderJobsEnabled,
|
reminderJobsEnabled,
|
||||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini'
|
||||||
|
|||||||
@@ -17,12 +17,21 @@ import {
|
|||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
|
import { createMiniAppAuthHandler } from './miniapp-auth'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
const bot = createTelegramBot(runtime.telegramBotToken)
|
const bot = createTelegramBot(runtime.telegramBotToken)
|
||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
|
|
||||||
const shutdownTasks: Array<() => Promise<void>> = []
|
const shutdownTasks: Array<() => Promise<void>> = []
|
||||||
|
const financeRepositoryClient =
|
||||||
|
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
|
||||||
|
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (financeRepositoryClient) {
|
||||||
|
shutdownTasks.push(financeRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.purchaseTopicIngestionEnabled) {
|
if (runtime.purchaseTopicIngestionEnabled) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
@@ -50,15 +59,10 @@ if (runtime.purchaseTopicIngestionEnabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.financeCommandsEnabled) {
|
if (runtime.financeCommandsEnabled) {
|
||||||
const financeRepositoryClient = createDbFinanceRepository(
|
const financeService = createFinanceCommandService(financeRepositoryClient!.repository)
|
||||||
runtime.databaseUrl!,
|
|
||||||
runtime.householdId!
|
|
||||||
)
|
|
||||||
const financeService = createFinanceCommandService(financeRepositoryClient.repository)
|
|
||||||
const financeCommands = createFinanceCommandsService(financeService)
|
const financeCommands = createFinanceCommandsService(financeService)
|
||||||
|
|
||||||
financeCommands.register(bot)
|
financeCommands.register(bot)
|
||||||
shutdownTasks.push(financeRepositoryClient.close)
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
console.warn('Finance commands are disabled. Set DATABASE_URL and HOUSEHOLD_ID to enable.')
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,13 @@ const server = createBotWebhookServer({
|
|||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
webhookHandler,
|
webhookHandler,
|
||||||
|
miniAppAuth: financeRepositoryClient
|
||||||
|
? createMiniAppAuthHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
repository: financeRepositoryClient.repository
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
scheduler:
|
scheduler:
|
||||||
reminderJobs && runtime.schedulerSharedSecret
|
reminderJobs && runtime.schedulerSharedSecret
|
||||||
? {
|
? {
|
||||||
|
|||||||
127
apps/bot/src/miniapp-auth.test.ts
Normal file
127
apps/bot/src/miniapp-auth.test.ts
Normal file
@@ -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<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||||
|
): 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
117
apps/bot/src/miniapp-auth.ts
Normal file
117
apps/bot/src/miniapp-auth.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<Response>
|
||||||
|
} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ describe('createBotWebhookServer', () => {
|
|||||||
webhookPath: '/webhook/telegram',
|
webhookPath: '/webhook/telegram',
|
||||||
webhookSecret: 'secret-token',
|
webhookSecret: 'secret-token',
|
||||||
webhookHandler: async () => new Response('ok', { status: 200 }),
|
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: {
|
scheduler: {
|
||||||
authorize: async (request) =>
|
authorize: async (request) =>
|
||||||
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
|
request.headers.get('x-household-scheduler-secret') === 'scheduler-secret',
|
||||||
@@ -71,6 +80,21 @@ describe('createBotWebhookServer', () => {
|
|||||||
expect(await response.text()).toBe('ok')
|
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 () => {
|
test('rejects scheduler request with missing secret', async () => {
|
||||||
const response = await server.fetch(
|
const response = await server.fetch(
|
||||||
new Request('http://localhost/jobs/reminder/utilities', {
|
new Request('http://localhost/jobs/reminder/utilities', {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ export interface BotWebhookServerOptions {
|
|||||||
webhookPath: string
|
webhookPath: string
|
||||||
webhookSecret: string
|
webhookSecret: string
|
||||||
webhookHandler: (request: Request) => Promise<Response> | Response
|
webhookHandler: (request: Request) => Promise<Response> | Response
|
||||||
|
miniAppAuth?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
scheduler?:
|
scheduler?:
|
||||||
| {
|
| {
|
||||||
pathPrefix?: string
|
pathPrefix?: string
|
||||||
@@ -32,6 +38,7 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
const normalizedWebhookPath = options.webhookPath.startsWith('/')
|
||||||
? options.webhookPath
|
? options.webhookPath
|
||||||
: `/${options.webhookPath}`
|
: `/${options.webhookPath}`
|
||||||
|
const miniAppAuthPath = options.miniAppAuth?.path ?? '/api/miniapp/session'
|
||||||
const schedulerPathPrefix = options.scheduler
|
const schedulerPathPrefix = options.scheduler
|
||||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||||
: null
|
: null
|
||||||
@@ -44,6 +51,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return json({ ok: true })
|
return json({ ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppAuth && url.pathname === miniAppAuthPath) {
|
||||||
|
return await options.miniAppAuth.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname !== normalizedWebhookPath) {
|
if (url.pathname !== normalizedWebhookPath) {
|
||||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
|
|||||||
70
apps/bot/src/telegram-miniapp-auth.test.ts
Normal file
70
apps/bot/src/telegram-miniapp-auth.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
85
apps/bot/src/telegram-miniapp-auth.ts
Normal file
85
apps/bot/src/telegram-miniapp-auth.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,15 @@ RUN bun run --filter @household/miniapp build
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
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/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
|
COPY --from=build /app/apps/miniapp/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
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 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;'"]
|
||||||
|
|||||||
3
apps/miniapp/config.template.js
Normal file
3
apps/miniapp/config.template.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
window.__HOUSEHOLD_CONFIG__ = {
|
||||||
|
botApiUrl: '${BOT_API_URL}'
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#121a24" />
|
||||||
<title>Solid App</title>
|
<title>Kojori House</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="/config.js"></script>
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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<SessionState, { status: 'ready' }> = {
|
||||||
|
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() {
|
function App() {
|
||||||
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
|
status: 'loading'
|
||||||
|
})
|
||||||
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('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 (
|
return (
|
||||||
<main>
|
<main class="shell">
|
||||||
<h1>Household Mini App</h1>
|
<div class="shell__backdrop shell__backdrop--top" />
|
||||||
<p>SolidJS scaffold is ready</p>
|
<div class="shell__backdrop shell__backdrop--bottom" />
|
||||||
|
|
||||||
|
<section class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{copy().appSubtitle}</p>
|
||||||
|
<h1>{copy().appTitle}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="locale-switch">
|
||||||
|
<span>{copy().language}</span>
|
||||||
|
<div class="locale-switch__buttons">
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'en' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale('en')}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'ru' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale('ru')}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Match when={session().status === 'loading'}>
|
||||||
|
<section class="hero-card">
|
||||||
|
<span class="pill">{copy().navHint}</span>
|
||||||
|
<h2>{copy().loadingTitle}</h2>
|
||||||
|
<p>{copy().loadingBody}</p>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match when={session().status === 'blocked'}>
|
||||||
|
<section class="hero-card">
|
||||||
|
<span class="pill">{copy().navHint}</span>
|
||||||
|
<h2>
|
||||||
|
{blockedSession()?.reason === 'telegram_only'
|
||||||
|
? copy().telegramOnlyTitle
|
||||||
|
: copy().unauthorizedTitle}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{blockedSession()?.reason === 'telegram_only'
|
||||||
|
? copy().telegramOnlyBody
|
||||||
|
: copy().unauthorizedBody}
|
||||||
|
</p>
|
||||||
|
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||||
|
{copy().reload}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
|
||||||
|
<Match when={session().status === 'ready'}>
|
||||||
|
<section class="hero-card">
|
||||||
|
<div class="hero-card__meta">
|
||||||
|
<span class="pill">
|
||||||
|
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().navHint}
|
||||||
|
</span>
|
||||||
|
<span class="pill pill--muted">
|
||||||
|
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
{copy().welcome},{' '}
|
||||||
|
{readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}
|
||||||
|
</h2>
|
||||||
|
<p>{copy().sectionBody}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="nav-grid">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['home', copy().home],
|
||||||
|
['balances', copy().balances],
|
||||||
|
['ledger', copy().ledger],
|
||||||
|
['house', copy().house]
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': activeNav() === key }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveNav(key)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<article class="panel panel--wide">
|
||||||
|
<p class="eyebrow">{copy().summaryTitle}</p>
|
||||||
|
<h3>{readySession()?.member.displayName}</h3>
|
||||||
|
<p>{renderPanel()}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<p class="eyebrow">{copy().cardAccess}</p>
|
||||||
|
<p>{copy().cardAccessBody}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<p class="eyebrow">{copy().cardLocale}</p>
|
||||||
|
<p>{copy().cardLocaleBody}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<p class="eyebrow">{copy().cardNext}</p>
|
||||||
|
<p>{copy().cardNextBody}</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
80
apps/miniapp/src/i18n.ts
Normal file
80
apps/miniapp/src/i18n.ts
Normal file
@@ -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<Locale, Record<string, string>>
|
||||||
@@ -1 +1,231 @@
|
|||||||
@import 'tailwindcss';
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
66
apps/miniapp/src/miniapp-api.ts
Normal file
66
apps/miniapp/src/miniapp-api.ts
Normal file
@@ -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<MiniAppSession> {
|
||||||
|
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 } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/miniapp/src/runtime-config.ts
Normal file
13
apps/miniapp/src/runtime-config.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
27
apps/miniapp/src/telegram-webapp.ts
Normal file
27
apps/miniapp/src/telegram-webapp.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
9
apps/miniapp/src/vite-env.d.ts
vendored
Normal file
9
apps/miniapp/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BOT_API_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
60
docs/specs/HOUSEBOT-040-miniapp-shell.md
Normal file
60
docs/specs/HOUSEBOT-040-miniapp-shell.md
Normal file
@@ -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.
|
||||||
@@ -141,6 +141,7 @@ module "mini_app_service" {
|
|||||||
|
|
||||||
env = {
|
env = {
|
||||||
NODE_ENV = var.environment
|
NODE_ENV = var.environment
|
||||||
|
BOT_API_URL = module.bot_api_service.uri
|
||||||
}
|
}
|
||||||
|
|
||||||
depends_on = [google_project_service.enabled]
|
depends_on = [google_project_service.enabled]
|
||||||
|
|||||||
Reference in New Issue
Block a user