fix(server,stage-ui,i18n): address PR #1376 review comments
This commit is contained in:
@@ -114,7 +114,6 @@
|
||||
- `HOST`
|
||||
- `PORT`
|
||||
- `API_SERVER_URL`
|
||||
- `CLIENT_URL`
|
||||
- `DATABASE_URL`
|
||||
- `REDIS_URL`
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user