diff --git a/apps/server/docs/ai-context/workers-and-runtime.md b/apps/server/docs/ai-context/workers-and-runtime.md index cc0b2fbf3..c9fb29603 100644 --- a/apps/server/docs/ai-context/workers-and-runtime.md +++ b/apps/server/docs/ai-context/workers-and-runtime.md @@ -114,7 +114,6 @@ - `HOST` - `PORT` - `API_SERVER_URL` -- `CLIENT_URL` - `DATABASE_URL` - `REDIS_URL` diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index db334aaf2..e8df24aed 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -107,7 +107,13 @@ function buildApp(deps: AppDeps) { const builtApp = app .use('*', sessionMiddleware(deps.auth)) - .use('*', bodyLimit({ maxSize: 1024 * 1024 })) + .use('*', async (c, next) => { + // Skip global body limit for ASR transcription route (has its own 25MB limit) + if (c.req.path === '/api/v1/audio/transcriptions') { + return next() + } + return bodyLimit({ maxSize: 1024 * 1024 })(c, next) + }) .onError((err, c) => { if (err instanceof ApiError) { logger.withError(err).warn('API error occurred') @@ -203,7 +209,7 @@ export async function createApp() { 'Database', logger, async (attempt) => { - const connection = createDrizzle(dependsOn.env.DATABASE_URL) + const connection = createDrizzle(dependsOn.env) try { await connection.db.execute('SELECT 1') @@ -348,7 +354,7 @@ export async function createApp() { return { app, injectWebSocket, - port: Number(resolved.env.PORT), + port: resolved.env.PORT, hostname: resolved.env.HOST, } } diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 989d71ee5..cd9ba3d8a 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -6,21 +6,9 @@ import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { bearer } from 'better-auth/plugins' -import * as authSchema from '../schemas/accounts' +import { getAuthTrustedOrigins } from '../utils/origin' -function extractOrigins(env: Env): string[] { - const origins = new Set() - for (const url of [env.CLIENT_URL, env.API_SERVER_URL]) { - try { - const { origin } = new URL(url) - origins.add(origin) - } - catch { - // skip malformed URLs - } - } - return [...origins] -} +import * as authSchema from '../schemas/accounts' // NOTICE: return type uses `any` to avoid TS2742 — betterAuth's inferred type // references internal pnpm paths (@better-auth/core) that aren't directly accessible @@ -43,7 +31,7 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) }, baseURL: env.API_SERVER_URL, - trustedOrigins: extractOrigins(env), + trustedOrigins: request => getAuthTrustedOrigins(env, request), // To skip state-mismatch errors // https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378 diff --git a/apps/server/src/libs/db.ts b/apps/server/src/libs/db.ts index 07045acab..e9fd2de39 100644 --- a/apps/server/src/libs/db.ts +++ b/apps/server/src/libs/db.ts @@ -1,3 +1,5 @@ +import type { Env } from './env' + import { useLogger } from '@guiiai/logg' import { migrate } from '@proj-airi/drizzle-orm-browser-migrator/pg' import { migrations } from '@proj-airi/server-schema' @@ -10,14 +12,16 @@ const logger = useLogger('db') export type Database = ReturnType['db'] -export function createDrizzle(dsn: string) { +type DrizzleEnv = Pick + +export function createDrizzle(env: DrizzleEnv) { const pool = new Pool({ - connectionString: dsn, - max: 20, - idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 5_000, + connectionString: env.DATABASE_URL, + max: env.DB_POOL_MAX, + idleTimeoutMillis: env.DB_POOL_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: env.DB_POOL_CONNECTION_TIMEOUT_MS, keepAlive: true, - keepAliveInitialDelayMillis: 10_000, + keepAliveInitialDelayMillis: env.DB_POOL_KEEPALIVE_INITIAL_DELAY_MS, }) pool.on('error', (err) => { diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 48327c402..f5ea1a7bb 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -4,14 +4,39 @@ import { env, exit } from 'node:process' import { useLogger } from '@guiiai/logg' import { injeca } from 'injeca' -import { nonEmpty, object, optional, parse, pipe, string } from 'valibot' +import { integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' + +function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) { + return optional( + pipe( + string(), + nonEmpty(`${envKey} must not be empty`), + transform(input => Number(input)), + integer(`${envKey} must be an integer`), + minValue(minimum, `${envKey} must be at least ${minimum}`), + ), + String(defaultValue), + ) +} + +function optionalNumberFromString(defaultValue: number, envKey: string, minimum: number, maximum: number) { + return optional( + pipe( + string(), + nonEmpty(`${envKey} must not be empty`), + transform(input => Number(input)), + minValue(minimum, `${envKey} must be at least ${minimum}`), + maxValue(maximum, `${envKey} must be at most ${maximum}`), + ), + String(defaultValue), + ) +} const EnvSchema = object({ HOST: optional(string(), '0.0.0.0'), - PORT: optional(string(), '3000'), + PORT: optionalIntegerFromString(3000, 'PORT', 1), API_SERVER_URL: optional(string(), 'http://localhost:3000'), - CLIENT_URL: optional(string(), 'https://airi.moerui.ai'), DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')), REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), @@ -26,14 +51,20 @@ const EnvSchema = object({ BILLING_EVENTS_STREAM: optional(string(), 'billing-events'), BILLING_EVENTS_CONSUMER_NAME: optional(string()), - BILLING_EVENTS_BATCH_SIZE: optional(string(), '10'), - BILLING_EVENTS_BLOCK_MS: optional(string(), '5000'), - BILLING_EVENTS_MIN_IDLE_MS: optional(string(), '30000'), + BILLING_EVENTS_BATCH_SIZE: optionalIntegerFromString(10, 'BILLING_EVENTS_BATCH_SIZE', 1), + BILLING_EVENTS_BLOCK_MS: optionalIntegerFromString(5000, 'BILLING_EVENTS_BLOCK_MS', 1), + BILLING_EVENTS_MIN_IDLE_MS: optionalIntegerFromString(30000, 'BILLING_EVENTS_MIN_IDLE_MS', 1), + + // Database pool + DB_POOL_MAX: optionalIntegerFromString(20, 'DB_POOL_MAX', 1), + DB_POOL_IDLE_TIMEOUT_MS: optionalIntegerFromString(30000, 'DB_POOL_IDLE_TIMEOUT_MS', 1), + DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1), + DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1), // OpenTelemetry OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'), OTEL_SERVICE_NAME: optional(string(), 'server'), - OTEL_TRACES_SAMPLING_RATIO: optional(string(), '1.0'), + OTEL_TRACES_SAMPLING_RATIO: optionalNumberFromString(1, 'OTEL_TRACES_SAMPLING_RATIO', 0, 1), OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()), OTEL_EXPORTER_OTLP_HEADERS: optional(string()), OTEL_DEBUG: optional(string()), diff --git a/apps/server/src/libs/otel.ts b/apps/server/src/libs/otel.ts index 345545b7f..0f37ffe56 100644 --- a/apps/server/src/libs/otel.ts +++ b/apps/server/src/libs/otel.ts @@ -129,7 +129,7 @@ export function initOtel(env: Env): OtelInstance | undefined { // Head-based sampling ratio: 1.0 = 100% (default), 0.1 = 10%, etc. // Metrics are always 100% accurate regardless of this setting. - const samplingRatio = Number.parseFloat(env.OTEL_TRACES_SAMPLING_RATIO) + const samplingRatio = env.OTEL_TRACES_SAMPLING_RATIO const sampler = new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(samplingRatio), }) diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index 1a63456a6..e19e6fec2 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -38,7 +38,7 @@ export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler = async (c, next) => { const user = c.get('user') if (!user) { - logger.withFields({ path: c.req.path, method: c.req.method }).warn('Unauthorized request blocked') + logger.withFields({ path: c.req.path, method: c.req.method }).debug('Unauthorized request blocked') throw createUnauthorizedError() } await next() diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index bab7914d8..4e38c1d1d 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { HonoEnv } from '../types/hono' +import { getConnInfo } from '@hono/node-server/conninfo' import { rateLimiter as createRateLimiter } from 'hono-rate-limiter' interface RateLimitOptions { @@ -21,8 +22,20 @@ export function rateLimiter(opts: RateLimitOptions) { return createRateLimiter({ windowMs: opts.windowSec * 1000, limit: opts.max, + // NOTICE: keep `draft-6` so the middleware emits the widely supported + // `RateLimit-*` header set. `draft-7`/`draft-8` switch to newer combined + // header formats that are easier to break in existing clients and proxies. standardHeaders: 'draft-6', keyGenerator: opts.keyGenerator - ?? (c => c.get('user')?.id ?? c.req.header('x-forwarded-for') ?? 'anonymous'), + ?? ((c) => { + const userId = c.get('user')?.id + if (userId) + return userId + + // NOTICE: prefer hono conninfo (uses underlying socket address) over + // x-forwarded-for which can be spoofed. Falls back to header then 'anonymous'. + const info = getConnInfo(c) + return info.remote?.address ?? c.req.header('x-forwarded-for') ?? 'anonymous' + }), }) } diff --git a/apps/server/src/routes/characters.ts b/apps/server/src/routes/characters.ts index 25d02becb..945b2f114 100644 --- a/apps/server/src/routes/characters.ts +++ b/apps/server/src/routes/characters.ts @@ -41,6 +41,8 @@ export function createCharacterRoutes(characterService: CharacterService) { throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) } + // NOTICE: Cast needed because valibot schema defines ownerId/creatorId as optional + // (user input), but we inject them from the auth context before passing to the service. const character = await characterService.create({ ...result.output, character: { diff --git a/apps/server/src/routes/chat-ws.ts b/apps/server/src/routes/chat-ws.ts index e46cbe8f8..14aba6115 100644 --- a/apps/server/src/routes/chat-ws.ts +++ b/apps/server/src/routes/chat-ws.ts @@ -142,11 +142,19 @@ export function createChatWsHandlers( toSeq: result.toSeq, } - // Local broadcast (other connections on this instance, exclude sender) - broadcastToLocalDevices(userId, ctx, newMessages, broadcastPayload) + // Broadcast to all chat members (not just the sender) + const members = await chatService.getMembers(req!.chatId) + const memberUserIds = members + .filter(m => m.memberType === 'user' && m.userId != null) + .map(m => m.userId!) - // Cross-instance broadcast via Redis pub/sub - publishBroadcast(userId, broadcastPayload) + for (const memberUserId of memberUserIds) { + // For the sender, exclude the current connection + const excludeCtx = memberUserId === userId ? ctx : null + broadcastToLocalDevices(memberUserId, excludeCtx, newMessages, broadcastPayload) + // Cross-instance broadcast via Redis pub/sub + publishBroadcast(memberUserId, broadcastPayload) + } metrics?.wsMessagesSent.add(wireMessages.messages.length) return { seq: result.seq } diff --git a/apps/server/src/routes/flux.ts b/apps/server/src/routes/flux.ts index a3899cc0a..fdcc85043 100644 --- a/apps/server/src/routes/flux.ts +++ b/apps/server/src/routes/flux.ts @@ -3,9 +3,33 @@ import type { FluxAuditService } from '../services/flux-audit' import type { HonoEnv } from '../types/hono' import { Hono } from 'hono' +import { fallback, integer, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' import { authGuard } from '../middlewares/auth' +const FluxHistoryQuerySchema = object({ + limit: fallback( + pipe( + optional(string(), '20'), + nonEmpty(), + transform(input => Number.parseInt(input, 10)), + integer(), + transform(value => Math.min(Math.max(value, 1), 100)), + ), + 20, + ), + offset: fallback( + pipe( + optional(string(), '0'), + nonEmpty(), + transform(input => Number.parseInt(input, 10)), + integer(), + transform(value => Math.max(value, 0)), + ), + 0, + ), +}) + export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) { return new Hono() .use('*', authGuard) @@ -16,8 +40,10 @@ export function createFluxRoutes(fluxService: FluxService, fluxAuditService: Flu }) .get('/history', async (c) => { const user = c.get('user')! - const limit = Math.min(Math.max(Number(c.req.query('limit') || '20'), 1), 100) - const offset = Math.max(Number(c.req.query('offset') || '0'), 0) + const { limit, offset } = parse(FluxHistoryQuerySchema, { + limit: c.req.query('limit'), + offset: c.req.query('offset'), + }) const { records, hasMore } = await fluxAuditService.getHistory(user.id, limit, offset) diff --git a/apps/server/src/routes/stripe.ts b/apps/server/src/routes/stripe.ts index 031a7ab17..7657f2db6 100644 --- a/apps/server/src/routes/stripe.ts +++ b/apps/server/src/routes/stripe.ts @@ -17,6 +17,7 @@ import { authGuard } from '../middlewares/auth' import { configGuard } from '../middlewares/config-guard' import { rateLimiter } from '../middlewares/rate-limit' import { createBadRequestError, createServiceUnavailableError } from '../utils/error' +import { resolveTrustedRequestOrigin } from '../utils/origin' const logger = useLogger('stripe') @@ -61,6 +62,11 @@ export function createStripeRoutes( const customer = await stripeService.getCustomerByUserId(user.id) const stripeCustomerId = customer?.stripeCustomerId + const redirectBase = resolveTrustedRequestOrigin(c.req.raw) + if (!redirectBase) { + throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN') + } + const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [ @@ -76,8 +82,8 @@ export function createStripeRoutes( }, ], mode: 'payment', - success_url: `${env.CLIENT_URL}/settings/flux?success=true`, - cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`, + success_url: `${redirectBase}/settings/flux?success=true`, + cancel_url: `${redirectBase}/settings/flux?canceled=true`, customer: stripeCustomerId, customer_email: stripeCustomerId ? undefined : user.email, metadata: { @@ -132,9 +138,14 @@ export function createStripeRoutes( if (!customer) throw createBadRequestError('No billing account found', 'NO_CUSTOMER') + const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw) + if (!portalReturnBase) { + throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN') + } + const portalSession = await stripe.billingPortal.sessions.create({ customer: customer.stripeCustomerId, - return_url: `${env.CLIENT_URL}/settings/flux`, + return_url: `${portalReturnBase}/settings/flux`, }) return c.json({ url: portalSession.url }) diff --git a/apps/server/src/routes/tests/stripe.test.ts b/apps/server/src/routes/tests/stripe.test.ts index 427acf4ef..951eb54a0 100644 --- a/apps/server/src/routes/tests/stripe.test.ts +++ b/apps/server/src/routes/tests/stripe.test.ts @@ -65,7 +65,7 @@ function createMockConfigKV(overrides: Record = {}): ConfigKVServic const testEnv = { STRIPE_SECRET_KEY: 'sk_test_fake', STRIPE_WEBHOOK_SECRET: 'whsec_test_fake', - CLIENT_URL: 'http://localhost:3000', + API_SERVER_URL: 'http://localhost:8787', } as any const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' } diff --git a/apps/server/src/routes/v1completions.ts b/apps/server/src/routes/v1completions.ts index b978061fa..5d77dee0f 100644 --- a/apps/server/src/routes/v1completions.ts +++ b/apps/server/src/routes/v1completions.ts @@ -1,8 +1,9 @@ import type { Context } from 'hono' +import type { MqService } from '../libs/mq' import type { LlmMetrics } from '../libs/otel' import type { UsageInfo } from '../services/billing/billing' -import type { BillingMqService } from '../services/billing/billing-mq' +import type { BillingEvent } from '../services/billing/billing-events' import type { BillingService } from '../services/billing/billing-service' import type { ConfigKVService } from '../services/config-kv' import type { FluxService } from '../services/flux' @@ -42,7 +43,7 @@ function normalizeBaseUrl(gatewayBaseUrl: string): string { return gatewayBaseUrl.endsWith('/') ? gatewayBaseUrl : `${gatewayBaseUrl}/` } -export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: BillingMqService, llm?: LlmMetrics | null) { +export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: MqService, llm?: LlmMetrics | null) { const logger = useLogger('v1-completions').useGlobalConfig() function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) { @@ -153,7 +154,12 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi } } finally { - await writer.close() + try { + await writer.close() + } + catch (err) { + logger.withError(err).warn('Failed to close stream writer') + } // Extract usage from final SSE data lines let usage: UsageInfo = {} diff --git a/apps/server/src/scripts/auth.ts b/apps/server/src/scripts/auth.ts index 8d4ceec89..48f12b1e6 100644 --- a/apps/server/src/scripts/auth.ts +++ b/apps/server/src/scripts/auth.ts @@ -5,4 +5,4 @@ import { createDrizzle } from '../libs/db' import { parseEnv } from '../libs/env' const env = parseEnv(process.env) -export default createAuth(createDrizzle(env.DATABASE_URL).db, env) +export default createAuth(createDrizzle(env).db, env) diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/chats.ts index 377619bc1..63fc77bea 100644 --- a/apps/server/src/services/chats.ts +++ b/apps/server/src/services/chats.ts @@ -173,6 +173,14 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu }, async addMember(userId: string, chatId: string, member: { type: ChatMemberType, userId?: string, characterId?: string }) { + // Validate that user-type members have a userId and non-user members have a characterId + if (member.type === 'user' && !member.userId) { + throw new Error('userId is required for user-type members') + } + if (member.type !== 'user' && !member.characterId) { + throw new Error('characterId is required for non-user-type members') + } + return db.transaction(async (tx) => { await verifyMembership(tx, chatId, userId) @@ -187,6 +195,12 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu }) }, + async getMembers(chatId: string) { + return db.query.chatMembers.findMany({ + where: eq(schema.chatMembers.chatId, chatId), + }) + }, + async removeMember(userId: string, chatId: string, memberId: string) { return db.transaction(async (tx) => { await verifyMembership(tx, chatId, userId) @@ -260,11 +274,12 @@ export function createChatService(db: Database, metrics?: EngagementMetrics | nu await tx.insert(schema.messages).values(values) } - // Update existing messages (content + updatedAt only) + // Update existing messages (content + updatedAt + seq bump) for (const m of updateMsgs) { + currentSeq++ await tx.update(schema.messages) - .set({ content: m.content, updatedAt: now }) - .where(eq(schema.messages.id, m.id)) + .set({ content: m.content, seq: currentSeq, updatedAt: now }) + .where(and(eq(schema.messages.id, m.id), eq(schema.messages.chatId, chatId))) } // Update chat updatedAt diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/config-kv.ts index 69a233dee..f81a866af 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/config-kv.ts @@ -40,11 +40,33 @@ const KEY_PREFIX = 'config:' function parseValue(key: K, raw: string): ConfigDefinitions[K] { if (key === 'FLUX_PACKAGES') return JSON.parse(raw) as ConfigDefinitions[K] - if (NUMERIC_KEYS.has(key)) - return Number(raw) as ConfigDefinitions[K] + if (NUMERIC_KEYS.has(key)) { + const num = Number(raw) + if (!Number.isFinite(num)) + throw new Error(`Config key ${key} has non-finite numeric value: ${raw}`) + return num as ConfigDefinitions[K] + } return raw as ConfigDefinitions[K] } +function serializeValue(key: K, value: ConfigDefinitions[K]): string { + if (key === 'FLUX_PACKAGES') { + const packages = parse(array(object({ amount: number(), label: string(), price: string() })), value) + return JSON.stringify(packages) + } + + if (NUMERIC_KEYS.has(key)) { + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new Error(`Config key ${key} must be a finite number`) + return String(value) + } + + if (typeof value !== 'string') + throw new Error(`Config key ${key} must be a string`) + + return value +} + /** * Resolve a config value: read from Redis, then apply valibot default if missing. * Returns `undefined` if both Redis and schema have no value (required key, not set). @@ -85,7 +107,7 @@ export function createConfigKVService(redis: Redis) { }, async set(key: K, value: ConfigDefinitions[K]): Promise { - const serialized = key === 'FLUX_PACKAGES' ? JSON.stringify(value) : String(value) + const serialized = serializeValue(key, value) await redis.set(`${KEY_PREFIX}${key}`, serialized) }, } diff --git a/apps/server/src/services/tests/config-kv.test.ts b/apps/server/src/services/tests/config-kv.test.ts index 4b7ed8840..661a4301e 100644 --- a/apps/server/src/services/tests/config-kv.test.ts +++ b/apps/server/src/services/tests/config-kv.test.ts @@ -25,7 +25,7 @@ describe('configKVService', () => { it('get should throw 503 when key is not set', async () => { await expect(service.getOrThrow('FLUX_PER_CENT')) .rejects - .toThrow('Config key "FLUX_PER_CENT" is not set in Redis') + .toThrow('Service configuration is incomplete') }) it('get should return numeric value when key is set', async () => { @@ -65,6 +65,12 @@ describe('configKVService', () => { expect(redis._store.get('config:FLUX_PER_CENT')).toBe('10') }) + it('set should reject non-string values for string config keys', async () => { + await expect(service.set('GATEWAY_BASE_URL', { url: 'https://example.com' } as any)) + .rejects + .toThrow('Config key GATEWAY_BASE_URL must be a string') + }) + it('set then get should round-trip correctly', async () => { await service.set('INITIAL_USER_FLUX', 500) diff --git a/apps/server/src/utils/id.ts b/apps/server/src/utils/id.ts index dad63186c..a3739881c 100644 --- a/apps/server/src/utils/id.ts +++ b/apps/server/src/utils/id.ts @@ -1,12 +1,17 @@ +// NOTICE: 64 chars is a power of 2, so `byte % 64` introduces no modulo bias. +// 21 chars at 6 bits each = 126 bits of entropy, sufficient for collision resistance. +export const NANOID_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-' +export const NANOID_DEFAULT_SIZE = 21 + /** - * Simple nanoid implementation to avoid dependencies + * Simple nanoid implementation to avoid dependencies. + * Generates a URL-safe, cryptographically random ID. */ -export function nanoid(size = 21): string { - const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-' +export function nanoid(size = NANOID_DEFAULT_SIZE): string { let id = '' const bytes = crypto.getRandomValues(new Uint8Array(size)) for (let i = 0; i < size; i++) { - id += alphabet[bytes[i] % alphabet.length] + id += NANOID_ALPHABET[bytes[i] % NANOID_ALPHABET.length] } return id } diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index fbb1852e8..f7e812cf5 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -1,3 +1,14 @@ +import type { Env } from '../libs/env' + +function getOriginFromUrl(url: string): string | undefined { + try { + return new URL(url).origin + } + catch { + return undefined + } +} + export function getTrustedOrigin(origin: string): string { // 1. Allow Dev (Localhost with any port) if (!origin || origin.startsWith('http://localhost:')) { @@ -18,3 +29,38 @@ export function getTrustedOrigin(origin: string): string { // Default: Block return '' } + +export function resolveTrustedRequestOrigin(request: Request): string | undefined { + const refererOrigin = getOriginFromUrl(request.headers.get('referer') ?? '') + if (refererOrigin) { + const trustedRefererOrigin = getTrustedOrigin(refererOrigin) + if (trustedRefererOrigin) { + return trustedRefererOrigin + } + } + + const requestOrigin = request.headers.get('origin') ?? '' + const trustedRequestOrigin = getTrustedOrigin(requestOrigin) + if (trustedRequestOrigin) { + return trustedRequestOrigin + } + + return undefined +} + +export function getAuthTrustedOrigins(env: Pick, request?: Request): string[] { + const origins = new Set() + const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL) + if (apiServerOrigin) { + origins.add(apiServerOrigin) + } + + if (request) { + const requestOrigin = resolveTrustedRequestOrigin(request) + if (requestOrigin) { + origins.add(requestOrigin) + } + } + + return [...origins] +} diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts new file mode 100644 index 000000000..3c9a1cfbc --- /dev/null +++ b/apps/server/src/utils/tests/origin.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { getAuthTrustedOrigins, getTrustedOrigin, resolveTrustedRequestOrigin } from '../origin' + +describe('origin utils', () => { + it('allows localhost origins', () => { + expect(getTrustedOrigin('http://localhost:5173')).toBe('http://localhost:5173') + }) + + it('rejects untrusted origins', () => { + expect(getTrustedOrigin('https://example.com')).toBe('') + }) + + it('prefers a trusted referer origin', () => { + const request = new Request('http://localhost/api/stripe/checkout', { + headers: { + referer: 'https://airi.moeru.ai/settings/flux', + origin: 'https://example.com', + }, + }) + + expect(resolveTrustedRequestOrigin(request)).toBe('https://airi.moeru.ai') + }) + + it('falls back to a trusted origin header when referer is missing', () => { + const request = new Request('http://localhost/api/stripe/checkout', { + headers: { + origin: 'http://localhost:5173', + }, + }) + + expect(resolveTrustedRequestOrigin(request)).toBe('http://localhost:5173') + }) + + it('collects api and request origins for auth', () => { + const request = new Request('http://localhost/api/auth/sign-in/social', { + headers: { + origin: 'http://localhost:5173', + }, + }) + + expect(getAuthTrustedOrigins({ API_SERVER_URL: 'https://api.airi.moeru.ai' } as any, request)).toEqual([ + 'https://api.airi.moeru.ai', + 'http://localhost:5173', + ]) + }) +}) diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 3750d5cda..5dd816fc5 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -27,8 +27,8 @@ dialogs: next: Next retry: Retry start: Let's do it! - loginPrompt: Login to use the official AIRI provider for the best experience. - loginAction: Login + loginPrompt: Sign in to use the official AIRI provider for the best experience. + loginAction: Sign in localSetup: Configure Local Provider flux: Flux buyFlux: Charge Flux @@ -62,6 +62,19 @@ controls-island: auto: Auto (responsive) large: Large (default) small: Small +analytics: + notice: + title: Usage analytics + description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected. + privacyPrefix: Read the + privacyLink: privacy policy + onboardingHint: You can turn analytics off later in Settings > System > General. + settingsHint: You can turn analytics off at any time. + toggle: + title: Enable usage analytics + description: Turn this off to opt out of analytics collection. + disabled: + title: Analytics disabled for this development build live2d: change-model: from-file: Load from File @@ -490,15 +503,6 @@ pages: title: Flux Packages buy: Charge description: Flux packages to choose from. - amount_500: - label: 500 Flux - price: '$5' - amount_1000: - label: 1000 Flux - price: '$10' - amount_5000: - label: 5000 Flux - price: '$45' providers: explained: chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama. @@ -841,6 +845,9 @@ pages: xai: description: x.ai title: xAI + zai: + description: z.ai + title: Z.ai 302-ai: description: 302.AI title: 302.AI diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 3e23d26e2..64bb05788 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -59,6 +59,19 @@ controls-island: auto: 自动(响应式) large: 大(默认) small: 小 +analytics: + notice: + title: Usage analytics + description: AIRI collects anonymous usage analytics to help us understand how the app is used and improve stability. No personal data is collected. + privacyPrefix: Read the + privacyLink: privacy policy + onboardingHint: You can turn analytics off later in Settings > System > General. + settingsHint: You can turn analytics off at any time. + toggle: + title: Enable usage analytics + description: Turn this off to opt out of analytics collection. + disabled: + title: Analytics disabled for this development build live2d: change-model: from-file: 从文件加载 @@ -472,15 +485,9 @@ pages: loadMore: 加载更多 delayHint: 消耗记录可能有最多 1 分钟的延迟 packages: - amount_500: - label: 500 Flux - price: '$5' - amount_1000: - label: 1000 Flux - price: '$10' - amount_5000: - label: 5000 Flux - price: '$45' + title: Flux 套餐 + buy: 充能 + description: 选择 Flux 充值套餐 providers: explained: chat: 文本生成模型服务来源,例如 OpenRouter, OpenAI, Ollama @@ -810,6 +817,9 @@ pages: xai: description: X.AI title: xAI + zai: + description: z.ai + title: Z.ai 302-ai: description: 302.AI title: 302.AI diff --git a/packages/server-sdk/package.json b/packages/server-sdk/package.json index e72c9cfe5..66ca6aed6 100644 --- a/packages/server-sdk/package.json +++ b/packages/server-sdk/package.json @@ -37,7 +37,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", "@proj-airi/server-sdk-shared": "workspace:^", "@proj-airi/server-shared": "workspace:^", diff --git a/packages/stage-ui/src/components/auth/LoginDrawer.vue b/packages/stage-ui/src/components/auth/LoginDrawer.vue index 54cf2671f..08480d0dc 100644 --- a/packages/stage-ui/src/components/auth/LoginDrawer.vue +++ b/packages/stage-ui/src/components/auth/LoginDrawer.vue @@ -22,10 +22,8 @@ const loading = ref>({ async function handleSignIn(provider: OAuthProvider) { loading.value[provider] = true try { - await Promise.all([ - () => signIn(provider), - fetchSession, - ]) + await signIn(provider) + await fetchSession() } catch (error) { toast.error(error instanceof Error ? error.message : 'An unknown error occurred') diff --git a/packages/stage-ui/src/composables/use-auth-provider-sync.ts b/packages/stage-ui/src/composables/use-auth-provider-sync.ts index cba42c330..491177e48 100644 --- a/packages/stage-ui/src/composables/use-auth-provider-sync.ts +++ b/packages/stage-ui/src/composables/use-auth-provider-sync.ts @@ -75,5 +75,29 @@ export function useAuthProviderSync() { for (const { id } of AUTH_ACTIVATED_PROVIDERS) { providersStore.setProviderUnconfigured(id) } + + // Reset active provider/model if they belong to an auth-activated provider + for (const { id, module } of AUTH_ACTIVATED_PROVIDERS) { + switch (module) { + case 'consciousness': + if (consciousnessStore.activeProvider === id) { + consciousnessStore.activeProvider = '' + consciousnessStore.activeModel = '' + } + break + case 'speech': + if (speechStore.activeSpeechProvider === id) { + speechStore.activeSpeechProvider = '' + speechStore.activeSpeechModel = '' + } + break + case 'hearing': + if (hearingStore.activeTranscriptionProvider === id) { + hearingStore.activeTranscriptionProvider = '' + hearingStore.activeTranscriptionModel = '' + } + break + } + } }) } diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index 40d67b535..b3f999e1c 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -22,13 +22,16 @@ export function initializeAuth() { export async function fetchSession() { const { data } = await authClient.getSession() + const authStore = useAuthStore() if (data) { - const authStore = useAuthStore() authStore.user = data.user authStore.session = data.session return true } + // Session expired or invalid — clear stale auth state from localStorage + authStore.user = null + authStore.session = null return false } diff --git a/packages/stage-ui/src/libs/providers/providers/official/index.ts b/packages/stage-ui/src/libs/providers/providers/official/index.ts index beaa0f42c..e6f324b59 100644 --- a/packages/stage-ui/src/libs/providers/providers/official/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/official/index.ts @@ -42,6 +42,7 @@ export const providerOfficialChat = defineProvider({ }, }) +// TODO: STT / TTS // TTS and ASR official providers — uncomment to re-enable: // // export const providerOfficialSpeech = defineProvider({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a2ba64fa..fd62e2c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2278,9 +2278,6 @@ importers: packages/server-sdk: dependencies: - '@moeru/eventa': - specifier: 'catalog:' - version: 1.0.0-beta.2(electron@41.0.3)(h3@2.0.1-rc.19(crossws@0.4.4(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d)))) '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17