fix(server,stage-ui,i18n): address PR #1376 review comments

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent 8322c2c622
commit bf85158aa9
29 changed files with 363 additions and 89 deletions
@@ -114,7 +114,6 @@
- `HOST`
- `PORT`
- `API_SERVER_URL`
- `CLIENT_URL`
- `DATABASE_URL`
- `REDIS_URL`
+9 -3
View File
@@ -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,
}
}
+3 -15
View File
@@ -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<string>()
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
+10 -6
View File
@@ -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<typeof createDrizzle>['db']
export function createDrizzle(dsn: string) {
type DrizzleEnv = Pick<Env, 'DATABASE_URL' | 'DB_POOL_MAX' | 'DB_POOL_IDLE_TIMEOUT_MS' | 'DB_POOL_CONNECTION_TIMEOUT_MS' | 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS'>
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) => {
+38 -7
View File
@@ -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()),
+1 -1
View File
@@ -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),
})
+1 -1
View File
@@ -38,7 +38,7 @@ export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler<HonoEnv
export const authGuard: MiddlewareHandler<HonoEnv> = 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()
+14 -1
View File
@@ -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<HonoEnv>({
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'
}),
})
}
+2
View File
@@ -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: {
+12 -4
View File
@@ -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 }
+28 -2
View File
@@ -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<HonoEnv>()
.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)
+14 -3
View File
@@ -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 })
+1 -1
View File
@@ -65,7 +65,7 @@ function createMockConfigKV(overrides: Record<string, any> = {}): 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' }
+9 -3
View File
@@ -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<BillingEvent>, 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 = {}
+1 -1
View File
@@ -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)
+18 -3
View File
@@ -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
+25 -3
View File
@@ -40,11 +40,33 @@ const KEY_PREFIX = 'config:'
function parseValue<K extends keyof ConfigDefinitions>(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<K extends keyof ConfigDefinitions>(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<K extends keyof ConfigDefinitions>(key: K, value: ConfigDefinitions[K]): Promise<void> {
const serialized = key === 'FLUX_PACKAGES' ? JSON.stringify(value) : String(value)
const serialized = serializeValue(key, value)
await redis.set(`${KEY_PREFIX}${key}`, serialized)
},
}
@@ -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)
+9 -4
View File
@@ -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
}
+46
View File
@@ -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<Env, 'API_SERVER_URL'>, request?: Request): string[] {
const origins = new Set<string>()
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]
}
@@ -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',
])
})
})
+18 -11
View File
@@ -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
@@ -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
-1
View File
@@ -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:^",
@@ -22,10 +22,8 @@ const loading = ref<Record<OAuthProvider, boolean>>({
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')
@@ -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
}
}
})
}
+4 -1
View File
@@ -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
}
@@ -42,6 +42,7 @@ export const providerOfficialChat = defineProvider({
},
})
// TODO: STT / TTS
// TTS and ASR official providers — uncomment to re-enable:
//
// export const providerOfficialSpeech = defineProvider({
-3
View File
@@ -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