feat(api): hot-reload ConfigKV from Postgres (#2289)

## Summary

- Add the `config_kv` schema and Drizzle migration `0020`.
- Keep the ConfigKV schema, cache store, and invalidation contract in
the Resource API.
- Read ConfigKV through a five-minute Redis cache with PostgreSQL
fallback.
- Reload Router and TTS voice state through `configkv:invalidate`.
- Keep Auth rate limits fixed at 20 requests per 60 seconds.

## Stack

- Depends on #2294 for the Redis test implementation.
- This PR adds ConfigKV-specific cache-aside and Pub/Sub tests on top of
that implementation.

## Deployment

Run migration `0020` before this runtime reaches production traffic.

Then freeze ConfigKV writes. Audit and backfill the data with
[proj-airi/backend#2](https://github.com/proj-airi/backend/pull/2).
Merge
[proj-airi/backend#4](https://github.com/proj-airi/backend/pull/4)
first, so
the fixed Auth rate-limit keys are skipped.

Keep writes frozen until the hashes match and two API instances pass the
Pub/Sub reload check. This PR does not run production DDL or data
migration.

## Verification

- `pnpm exec vitest run <ConfigKV cache store, sync subscriber, and Auth
rate-limit tests>` (12 tests passed)
- `pnpm -F @proj-airi/api-server typecheck`
- `git diff --check`

See #2294 for its frozen-install, ESLint, and 73-test verification.

## Visual changes

None. This PR changes backend persistence and rate-limit wiring only.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added centralized configuration storage with validation, caching,
refresh, and automatic synchronization across services.
- Configuration updates now refresh related language-model and
text-to-speech settings automatically.

- **Bug Fixes**
- Improved recovery after service reconnects by clearing stale
configuration and reloading current values.
- Invalid or unavailable configuration data now produces clearer
service-unavailable responses.

- **Changes**
- Authentication rate limiting now uses a consistent limit of 20
requests per minute per client.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: RainbowBird <git@luoling.moe>
Signed-off-by: RainbowBird <rbxin2003@outlook.com>
This commit is contained in:
RainbowBird
2026-08-15 22:24:32 +08:00
committed by GitHub
parent ab5e43ae0c
commit 88625a8d84
25 changed files with 4269 additions and 261 deletions
+11
View File
@@ -0,0 +1,11 @@
# Server Guide
## Runtime contracts
- Use Valibot for all server data that crosses a trust boundary.
- This includes HTTP data, Pub/Sub messages, queue jobs, WebSocket events, database JSON, and provider responses.
- Define each schema beside the contract owner.
- Use `parse` if the caller converts invalid data into an error.
- Use `safeParse` if the caller branches on valid and invalid data.
- Do not use `typeof`, `Record<string, unknown>`, or type casts as runtime input validation.
- Infer TypeScript types from Valibot schemas. Do not duplicate the contract in an interface.
@@ -0,0 +1,5 @@
CREATE TABLE "config_kv" (
"key" text PRIMARY KEY NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,13 @@
"when": 1785843526589,
"tag": "0019_low_namora",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1786787455390,
"tag": "0020_smart_war_machine",
"breakpoints": true
}
]
}
+4 -2
View File
@@ -55,6 +55,7 @@ import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createVoicePackRoutes } from './routes/voice-packs'
import { createConfigKVService } from './services/adapters/config-kv'
import { createConfigKVStore } from './services/adapters/config-kv/store'
import { createPosthogSink } from './services/adapters/posthog'
import { createBillingService } from './services/domain/billing/billing-service'
import { createFluxMeter } from './services/domain/billing/flux-meter'
@@ -211,6 +212,7 @@ export async function buildApp(deps: AppDeps) {
// connection + lifecycle metrics; see services/llm-router/config-sync-subscriber.ts.
createConfigSyncSubscriber({
redis: deps.redis,
configKV: deps.configKV,
llmRouter: deps.llmRouter,
gatewayMetrics: deps.otel?.gateway ?? null,
instanceId: deps.env.OTEL_SERVICE_NAME,
@@ -482,8 +484,8 @@ export async function createApp() {
})
const configKV = injeca.provide('datastore:configKV', {
dependsOn: { redis },
build: ({ dependsOn }) => createConfigKVService(dependsOn.redis),
dependsOn: { db, redis },
build: ({ dependsOn }) => createConfigKVService(createConfigKVStore(dependsOn.db, dependsOn.redis)),
})
const posthogSink = injeca.provide('services:posthogSink', {
+8
View File
@@ -0,0 +1,8 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
/** Operator-managed configuration stored as its canonical JSON text. */
export const configKV = pgTable('config_kv', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
})
+1
View File
@@ -1,5 +1,6 @@
export * from './characters'
export * from './chats'
export * from './config-kv'
export * from './flux'
export * from './flux-transaction'
export * from './llm-request-log'
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { parseConfigKVInvalidation } from './contracts'
describe('configKV invalidation contract', () => {
it('accepts a declared ConfigKV key', () => {
expect(parseConfigKVInvalidation(JSON.stringify({
key: 'FLUX_PER_REQUEST',
version: 1,
publishedAt: 1,
}))).toMatchObject({ key: 'FLUX_PER_REQUEST' })
})
it('rejects an unknown ConfigKV key', () => {
expect(() => parseConfigKVInvalidation(JSON.stringify({
key: 'UNKNOWN_CONFIG_KEY',
version: 1,
publishedAt: 1,
}))).toThrow('ConfigKV invalidation key is unknown')
})
it('rejects a non-finite message version', () => {
expect(() => parseConfigKVInvalidation('{"key":"FLUX_PER_REQUEST","version":1e999,"publishedAt":1}'))
.toThrow('ConfigKV invalidation version must be a number')
})
})
@@ -0,0 +1,43 @@
import type { InferOutput } from 'valibot'
import type { ConfigKey } from './definitions'
import { finite, keyof, number, object, parse, parseJson, pipe, string } from 'valibot'
import { configEntrySchemas } from './definitions'
export const CONFIG_KV_CACHE_TTL_SECONDS = 300
export const CONFIG_KV_INVALIDATION_CHANNEL = 'configkv:invalidate'
const configKVInvalidationPayloadSchema = object({
key: keyof(
object(configEntrySchemas),
'ConfigKV invalidation key is unknown',
),
version: pipe(
number('ConfigKV invalidation version must be a number'),
finite('ConfigKV invalidation version must be a number'),
),
publishedAt: pipe(
number('ConfigKV invalidation publishedAt must be a number'),
finite('ConfigKV invalidation publishedAt must be a number'),
),
})
const configKVInvalidationSchema = pipe(
string('ConfigKV invalidation must be a string'),
parseJson({}, 'ConfigKV invalidation must be valid JSON'),
configKVInvalidationPayloadSchema,
)
export type ConfigKVInvalidation = InferOutput<typeof configKVInvalidationPayloadSchema>
/** Returns the Redis cache key for one ConfigKV entry. */
export function configKVCacheKey(key: ConfigKey): string {
return `cache:config:${key}`
}
/** Parses one ConfigKV invalidation message. */
export function parseConfigKVInvalidation(raw: string): ConfigKVInvalidation {
return parse(configKVInvalidationSchema, raw)
}
@@ -1,11 +1,6 @@
import type Redis from 'ioredis'
import type { InferOutput } from 'valibot'
import { errorMessageFrom } from '@moeru/std'
import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot'
import { createServiceUnavailableError } from '../../utils/error'
import { configRedisKey } from '../../utils/redis-keys'
import { any, array, boolean, check, nonEmpty, number, object, optional, picklist, pipe, record, regex, string } from 'valibot'
/**
* LLM/TTS router config tree. Single composite entry under configKV holds the
@@ -239,9 +234,9 @@ export const llmRouterConfigSchema = object({
* Config entry schemas are the single source of truth for:
* - runtime validation
* - default values
* - Redis serialization/deserialization shape
* - stored JSON shape
*/
const ConfigEntrySchemas = {
export const configEntrySchemas = {
FLUX_PER_REQUEST: optional(number(), 5),
INITIAL_USER_FLUX: optional(number(), 0),
FLUX_PER_1K_TOKENS: optional(number(), 1),
@@ -249,8 +244,6 @@ const ConfigEntrySchemas = {
// Debt-ledger TTL: residual TTS chars below 1 Flux are forgiven on expiry.
// 24h gives users a long-enough window for accumulated dust to settle naturally.
TTS_DEBT_TTL_SECONDS: optional(number(), 86400),
AUTH_RATE_LIMIT_MAX: optional(number(), 20),
AUTH_RATE_LIMIT_WINDOW_SEC: optional(number(), 60),
// No default — absent means top-up is not available yet
STRIPE_FLUX_PRODUCT_ID: optional(string()),
// No default — absent lets Stripe auto-select payment methods via Dashboard config
@@ -283,75 +276,8 @@ const ConfigEntrySchemas = {
UNSPEECH_UPSTREAM: optional(unspeechUpstreamSchema),
} as const
type ConfigDefinitions = {
[K in keyof typeof ConfigEntrySchemas]: InferOutput<(typeof ConfigEntrySchemas)[K]>
export type ConfigDefinitions = {
[K in keyof typeof configEntrySchemas]: InferOutput<(typeof configEntrySchemas)[K]>
}
type ConfigKey = keyof ConfigDefinitions
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
try {
return parse(ConfigEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is invalid',
'CONFIG_INVALID',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config parse error',
},
)
}
}
function serializeValue<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): string {
return JSON.stringify(parse(ConfigEntrySchemas[key], 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).
*/
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
if (raw !== null)
return parseValue(key, raw)
// Use the per-key schema with `undefined` to trigger the key default
try {
return parse(ConfigEntrySchemas[key], undefined) as ConfigDefinitions[K]
}
catch {
return undefined
}
}
export function createConfigKVService(redis: Redis) {
return {
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await redis.get(configRedisKey(key))
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await redis.get(configRedisKey(key))
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async set<K extends ConfigKey>(key: K, value: ConfigDefinitions[K]): Promise<void> {
const serialized = serializeValue(key, value)
await redis.set(configRedisKey(key), serialized)
},
}
}
export type ConfigKVService = ReturnType<typeof createConfigKVService>
export type ConfigKey = keyof ConfigDefinitions
@@ -1,24 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { configRedisKey } from '../../utils/redis-keys'
import { createConfigKVService } from './config-kv'
import { createConfigKVService } from './index'
function createMockRedis() {
function createMockStore() {
const store = new Map<string, string>()
return {
get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => { store.set(key, value) }),
getRaw: vi.fn(async (key: string) => store.get(key) ?? null),
getFreshRaw: vi.fn(async (key: string) => store.get(key) ?? null),
invalidateCache: vi.fn(async () => {}),
_store: store,
}
}
describe('configKVService', () => {
let redis: ReturnType<typeof createMockRedis>
let store: ReturnType<typeof createMockStore>
let service: ReturnType<typeof createConfigKVService>
beforeEach(() => {
redis = createMockRedis()
service = createConfigKVService(redis as any)
store = createMockStore()
service = createConfigKVService(store)
})
it('uses the ConfigKV schema as the key type', () => {
expectTypeOf(service.get('FLUX_PER_REQUEST')).toEqualTypeOf<Promise<number>>()
})
it('get should throw 503 when key is not set', async () => {
@@ -28,17 +32,17 @@ describe('configKVService', () => {
})
it('get should return numeric value when key is set', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '5')
store._store.set('FLUX_PER_REQUEST', '5')
const value = await service.getOrThrow('FLUX_PER_REQUEST')
expect(value).toBe(5)
})
it('get should read from correct prefixed key', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), '3')
it('get should read the requested ConfigKV key', async () => {
store._store.set('FLUX_PER_REQUEST', '3')
await service.getOrThrow('FLUX_PER_REQUEST')
expect(redis.get).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'))
expect(store.getRaw).toHaveBeenCalledWith('FLUX_PER_REQUEST')
})
it('getOptional should return schema default when key has one', async () => {
@@ -52,22 +56,22 @@ describe('configKVService', () => {
})
it('getOptional should return numeric value when key is set', async () => {
redis._store.set(configRedisKey('INITIAL_USER_FLUX'), '200')
store._store.set('INITIAL_USER_FLUX', '200')
const value = await service.getOptional('INITIAL_USER_FLUX')
expect(value).toBe(200)
})
it('getOptional should throw CONFIG_INVALID when Redis contains malformed JSON', async () => {
it('getOptional should throw CONFIG_INVALID when the store contains malformed JSON', async () => {
// ROOT CAUSE:
//
// If an operator edits config:LLM_ROUTER_CONFIG directly with invalid JSON,
// If an operator stores invalid LLM_ROUTER_CONFIG JSON in PostgreSQL,
// JSON.parse used to throw SyntaxError through the request handler and log
// it as an unhandled 500.
//
// We fixed this by translating stored config parse/validation failures into
// a stable API error at the configKV boundary.
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), '{"llm":{}')
store._store.set('LLM_ROUTER_CONFIG', '{"llm":{}')
await expect(service.getOptional('LLM_ROUTER_CONFIG'))
.rejects
@@ -77,8 +81,8 @@ describe('configKVService', () => {
})
})
it('getOptional should throw CONFIG_INVALID when Redis contains schema-invalid JSON', async () => {
redis._store.set(configRedisKey('FLUX_PER_REQUEST'), JSON.stringify('5'))
it('getOptional should throw CONFIG_INVALID when the store contains schema-invalid JSON', async () => {
store._store.set('FLUX_PER_REQUEST', JSON.stringify('5'))
await expect(service.getOptional('FLUX_PER_REQUEST'))
.rejects
@@ -88,32 +92,23 @@ describe('configKVService', () => {
})
})
it('set should write value to Redis with prefix', async () => {
await service.set('FLUX_PER_REQUEST', 10)
it('wraps database failures as CONFIG_UNAVAILABLE', async () => {
store.getRaw.mockRejectedValueOnce(new Error('database offline'))
expect(redis.set).toHaveBeenCalledWith(configRedisKey('FLUX_PER_REQUEST'), '10')
expect(redis._store.get(configRedisKey('FLUX_PER_REQUEST'))).toBe('10')
})
it('set should reject invalid values for string config keys', async () => {
await expect(service.set('STRIPE_FLUX_PRODUCT_ID', { id: 'prod_123' } as any))
await expect(service.getOrThrow('FLUX_PER_REQUEST'))
.rejects
.toThrow()
})
it('set then get should round-trip correctly', async () => {
await service.set('INITIAL_USER_FLUX', 500)
const value = await service.getOrThrow('INITIAL_USER_FLUX')
expect(value).toBe(500)
.toMatchObject({
statusCode: 503,
errorCode: 'CONFIG_UNAVAILABLE',
})
})
/**
* @example
* service.set('LLM_ROUTER_CONFIG', { asr: { models: { auto: model } } })
* store._store.set('LLM_ROUTER_CONFIG', JSON.stringify(config))
*/
it('llm router config should preserve official ASR model config', async () => {
await service.set('LLM_ROUTER_CONFIG', {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: { models: {} },
tts: { models: {} },
asr: {
@@ -136,7 +131,7 @@ describe('configKVService', () => {
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
})
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
const asr = value.asr
@@ -152,7 +147,7 @@ describe('configKVService', () => {
})
it('llm router config should preserve explicit LLM and TTS provider groups', async () => {
await service.set('LLM_ROUTER_CONFIG', {
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: {
models: {
'step-3.5-flash': {
@@ -240,7 +235,7 @@ describe('configKVService', () => {
fullChainTimeoutMs: 60000,
fallbackHttpCodes: [401, 402, 403, 429, 500, 502, 503, 504],
},
})
}))
const value = await service.getOrThrow('LLM_ROUTER_CONFIG')
const model = value.tts.models['stepfun/stepaudio-2.5-tts']
@@ -254,7 +249,7 @@ describe('configKVService', () => {
})
it('rejects a TTS provider group that references an unknown upstream', async () => {
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: { models: {} },
tts: {
models: {
@@ -287,7 +282,7 @@ describe('configKVService', () => {
})
it('rejects least-inflight routing without an explicit concurrency cap', async () => {
redis._store.set(configRedisKey('LLM_ROUTER_CONFIG'), JSON.stringify({
store._store.set('LLM_ROUTER_CONFIG', JSON.stringify({
llm: { models: {} },
tts: {
models: {
@@ -319,9 +314,11 @@ describe('configKVService', () => {
})
})
it('set should store string values as JSON strings', async () => {
await service.set('STRIPE_FLUX_PRODUCT_ID', 'prod_abc123')
it('refresh should bypass the ordinary store read', async () => {
store._store.set('STRIPE_FLUX_PRODUCT_ID', JSON.stringify('prod_abc123'))
expect(redis._store.get(configRedisKey('STRIPE_FLUX_PRODUCT_ID'))).toBe(JSON.stringify('prod_abc123'))
await expect(service.refresh('STRIPE_FLUX_PRODUCT_ID')).resolves.toBe('prod_abc123')
expect(store.getFreshRaw).toHaveBeenCalledWith('STRIPE_FLUX_PRODUCT_ID')
expect(store.getRaw).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,96 @@
import type { ConfigDefinitions, ConfigKey } from './definitions'
import type { ConfigKVStore } from './store'
import { errorMessageFrom } from '@moeru/std'
import { parse } from 'valibot'
import { createServiceUnavailableError } from '../../../utils/error'
import { configEntrySchemas } from './definitions'
export * from './definitions'
function parseValue<K extends ConfigKey>(key: K, raw: string): ConfigDefinitions[K] {
try {
return parse(configEntrySchemas[key], JSON.parse(raw)) as ConfigDefinitions[K]
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is invalid',
'CONFIG_INVALID',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config parse error',
},
)
}
}
/** Resolves a config value and applies the Valibot default when the row is missing. */
function resolveWithDefault<K extends ConfigKey>(key: K, raw: string | null): ConfigDefinitions[K] | undefined {
if (raw !== null)
return parseValue(key, raw)
try {
return parse(configEntrySchemas[key], undefined) as ConfigDefinitions[K]
}
catch {
return undefined
}
}
/**
* Creates the API's typed, read-only ConfigKV boundary.
*
* PostgreSQL owns persisted values. Redis must be available for every store
* operation. This layer preserves validation, defaults, and API errors.
*/
export function createConfigKVService(store: ConfigKVStore) {
async function loadRaw(key: ConfigKey, fresh = false): Promise<string | null> {
try {
return fresh ? await store.getFreshRaw(key) : await store.getRaw(key)
}
catch (error) {
throw createServiceUnavailableError(
'Service configuration is unavailable',
'CONFIG_UNAVAILABLE',
{
key,
message: errorMessageFrom(error) ?? 'Unknown config store error',
},
)
}
}
return {
async getOptional<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async getOrThrow<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
const raw = await loadRaw(key)
const value = resolveWithDefault(key, raw)
if (value === undefined)
throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET')
return value as Exclude<ConfigDefinitions[K], undefined>
},
async get<K extends ConfigKey>(key: K): Promise<Exclude<ConfigDefinitions[K], undefined>> {
return this.getOrThrow(key)
},
async refresh<K extends ConfigKey>(key: K): Promise<ConfigDefinitions[K] | null> {
const raw = await loadRaw(key, true)
const value = resolveWithDefault(key, raw)
return value ?? null
},
async invalidateCache<K extends ConfigKey>(key: K): Promise<void> {
await store.invalidateCache(key)
},
}
}
export type ConfigKVService = ReturnType<typeof createConfigKVService>
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../../libs/mock-db'
import { createTestRedis } from '../../../libs/tests/redis'
import { configKV } from '../../../schemas'
import { createConfigKVStore } from './store'
describe('configKV store', () => {
let db: Awaited<ReturnType<typeof mockDB>>
beforeEach(async () => {
db = await mockDB({ configKV })
})
it('returns a Redis cache hit without reading PostgreSQL', async () => {
const redis = createTestRedis()
await redis.set('cache:config:FLUX_PER_REQUEST', '7')
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('7')
expect(set).not.toHaveBeenCalled()
})
it('falls back to PostgreSQL and fills Redis for 300 seconds', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '8' })
const redis = createTestRedis()
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).resolves.toBe('8')
expect(set).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST', '8', 'EX', 300)
})
it('fails when Redis reads fail', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
const redis = createTestRedis()
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
})
it('returns null when PostgreSQL has no row', async () => {
const redis = createTestRedis()
const set = vi.spyOn(redis, 'set')
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('DEFAULT_CHAT_MODEL')).resolves.toBeNull()
expect(set).not.toHaveBeenCalled()
})
it('fails when Redis cannot store a PostgreSQL value', async () => {
await db.insert(configKV).values({ key: 'FLUX_PER_REQUEST', value: '9' })
const redis = createTestRedis()
vi.spyOn(redis, 'set').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.getRaw('FLUX_PER_REQUEST')).rejects.toThrow('redis offline')
})
it('deletes the derived cache entry during invalidation', async () => {
const redis = createTestRedis()
await redis.set('cache:config:LLM_ROUTER_CONFIG', '{}')
const del = vi.spyOn(redis, 'del')
const store = createConfigKVStore(db, redis)
await store.invalidateCache('LLM_ROUTER_CONFIG')
expect(del).toHaveBeenCalledWith('cache:config:LLM_ROUTER_CONFIG')
await expect(redis.get('cache:config:LLM_ROUTER_CONFIG')).resolves.toBeNull()
})
it('fails invalidation when Redis cannot delete the derived value', async () => {
const redis = createTestRedis()
vi.spyOn(redis, 'del').mockRejectedValueOnce(new Error('redis offline'))
const store = createConfigKVStore(db, redis)
await expect(store.invalidateCache('LLM_ROUTER_CONFIG')).rejects.toThrow('redis offline')
})
it('removes a stale cache entry when a fresh database read is missing', async () => {
const redis = createTestRedis()
await redis.set('cache:config:FLUX_PER_REQUEST', '20')
const del = vi.spyOn(redis, 'del')
const store = createConfigKVStore(db, redis)
await expect(store.getFreshRaw('FLUX_PER_REQUEST')).resolves.toBeNull()
expect(del).toHaveBeenCalledWith('cache:config:FLUX_PER_REQUEST')
await expect(redis.get('cache:config:FLUX_PER_REQUEST')).resolves.toBeNull()
})
})
@@ -0,0 +1,78 @@
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type Redis from 'ioredis'
import type { ConfigKey } from './definitions'
import { eq } from 'drizzle-orm'
import { configKV } from '../../../schemas/config-kv'
import { CONFIG_KV_CACHE_TTL_SECONDS, configKVCacheKey } from './contracts'
export interface ConfigKVStoreOptions {
/**
* Maximum lifetime of one derived Redis entry.
* @default 300
*/
cacheTtlSeconds?: number
}
/**
* Creates a read-only ConfigKV store with Redis cache-aside reads.
*
* PostgreSQL is the source of truth. A Redis error fails the operation so this
* boundary never serves ConfigKV while its cache dependency is unavailable.
*/
export function createConfigKVStore<TSchema extends Record<string, unknown>>(
db: NodePgDatabase<TSchema>,
redis: Redis,
options: ConfigKVStoreOptions = {},
) {
const cacheTtlSeconds = options.cacheTtlSeconds ?? CONFIG_KV_CACHE_TTL_SECONDS
async function readDatabase(key: ConfigKey): Promise<string | null> {
const rows = await db
.select({ value: configKV.value })
.from(configKV)
.where(eq(configKV.key, key))
.limit(1)
return rows[0]?.value ?? null
}
async function cacheValue(key: ConfigKey, value: string): Promise<void> {
await redis.set(configKVCacheKey(key), value, 'EX', cacheTtlSeconds)
}
async function deleteCachedValue(key: ConfigKey): Promise<void> {
await redis.del(configKVCacheKey(key))
}
return {
async getRaw(key: ConfigKey): Promise<string | null> {
const cached = await redis.get(configKVCacheKey(key))
if (cached !== null)
return cached
const value = await readDatabase(key)
if (value !== null)
await cacheValue(key, value)
return value
},
async getFreshRaw(key: ConfigKey): Promise<string | null> {
const value = await readDatabase(key)
if (value !== null) {
await cacheValue(key, value)
}
else {
await deleteCachedValue(key)
}
return value
},
async invalidateCache(key: ConfigKey): Promise<void> {
await deleteCachedValue(key)
},
}
}
export type ConfigKVStore = ReturnType<typeof createConfigKVStore>
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
import { createTestRedis } from '../../../libs/tests/redis'
import { CONFIG_KV_INVALIDATION_CHANNEL } from '../../adapters/config-kv/contracts'
import { createConfigSyncSubscriber } from './config-sync-subscriber'
function createHarness() {
const redis = createTestRedis()
const configKV = { invalidateCache: vi.fn(async () => {}) }
const llmRouter = {
invalidateConfig: vi.fn(),
invalidateTtsVoicesCache: vi.fn(async () => {}),
}
const logger = {
withError: vi.fn(() => logger),
warn: vi.fn(),
}
const { subscriber } = createConfigSyncSubscriber({
redis,
configKV,
llmRouter: llmRouter as never,
gatewayMetrics: null,
instanceId: 'api-test',
logger: logger as never,
})
return { configKV, llmRouter, redis, subscriber }
}
function message(key: string) {
return JSON.stringify({ key, version: 1, publishedAt: Date.now() })
}
async function settleInitialReconnect(harness: ReturnType<typeof createHarness>): Promise<void> {
await vi.waitFor(() => expect(harness.configKV.invalidateCache).toHaveBeenCalledTimes(2))
harness.configKV.invalidateCache.mockClear()
harness.llmRouter.invalidateConfig.mockClear()
harness.llmRouter.invalidateTtsVoicesCache.mockClear()
}
async function publishInvalidation(harness: ReturnType<typeof createHarness>, key: string): Promise<void> {
await harness.subscriber.subscribe(CONFIG_KV_INVALIDATION_CHANNEL)
const received = new Promise<void>((resolve) => {
harness.subscriber.once('message', () => resolve())
})
await harness.redis.publish(CONFIG_KV_INVALIDATION_CHANNEL, message(key))
await received
}
describe('configKV sync subscriber', () => {
it('invalidates router and voice state for LLM_ROUTER_CONFIG', async () => {
const harness = createHarness()
await settleInitialReconnect(harness)
await publishInvalidation(harness, 'LLM_ROUTER_CONFIG')
await vi.waitFor(() => expect(harness.llmRouter.invalidateConfig).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(harness.llmRouter.invalidateTtsVoicesCache).toHaveBeenCalledTimes(1))
})
it('invalidates only voice state for UNSPEECH_UPSTREAM', async () => {
const harness = createHarness()
await settleInitialReconnect(harness)
await publishInvalidation(harness, 'UNSPEECH_UPSTREAM')
await vi.waitFor(() => expect(harness.llmRouter.invalidateConfig).not.toHaveBeenCalled())
await vi.waitFor(() => expect(harness.llmRouter.invalidateTtsVoicesCache).toHaveBeenCalledTimes(1))
})
it('ignores ordinary ConfigKV notifications', async () => {
const harness = createHarness()
await settleInitialReconnect(harness)
await publishInvalidation(harness, 'FLUX_PER_REQUEST')
expect(harness.llmRouter.invalidateConfig).not.toHaveBeenCalled()
expect(harness.llmRouter.invalidateTtsVoicesCache).not.toHaveBeenCalled()
})
it('clears derived caches and local state after Redis reconnects', async () => {
const harness = createHarness()
await settleInitialReconnect(harness)
harness.subscriber.emit('ready')
await vi.waitFor(() => {
expect(harness.configKV.invalidateCache).toHaveBeenCalledTimes(2)
expect(harness.llmRouter.invalidateConfig).toHaveBeenCalledTimes(1)
expect(harness.llmRouter.invalidateTtsVoicesCache).toHaveBeenCalledTimes(1)
})
expect(harness.configKV.invalidateCache).toHaveBeenNthCalledWith(1, 'LLM_ROUTER_CONFIG')
expect(harness.configKV.invalidateCache).toHaveBeenNthCalledWith(2, 'UNSPEECH_UPSTREAM')
})
})
@@ -2,8 +2,11 @@ import type { useLogger } from '@guiiai/logg'
import type Redis from 'ioredis'
import type { GatewayMetrics } from '../../../otel'
import type { ConfigKVService } from '../../adapters/config-kv'
import type { LlmRouterService } from './router'
import { CONFIG_KV_INVALIDATION_CHANNEL, parseConfigKVInvalidation } from '../../adapters/config-kv/contracts'
/**
* Dependencies needed to wire the cross-instance config invalidation
* subscriber.
@@ -15,6 +18,8 @@ export interface ConfigSyncSubscriberOptions {
* connection in subscribe mode.
*/
redis: Redis
/** Typed ConfigKV reader whose Redis cache is cleared after reconnects. */
configKV: Pick<ConfigKVService, 'invalidateCache'>
/** Router service whose in-memory `LLM_ROUTER_CONFIG` cache we invalidate. */
llmRouter: LlmRouterService
/**
@@ -68,6 +73,19 @@ export interface ConfigSyncSubscriber {
export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): ConfigSyncSubscriber {
const subscriber = opts.redis.duplicate()
async function invalidateRouterState(source: 'pubsub' | 'reconnect'): Promise<void> {
await Promise.all([
opts.configKV.invalidateCache('LLM_ROUTER_CONFIG'),
opts.configKV.invalidateCache('UNSPEECH_UPSTREAM'),
])
opts.llmRouter.invalidateConfig()
await opts.llmRouter.invalidateTtsVoicesCache()
opts.gatewayMetrics?.configReload.add(1, {
source,
service_instance_id: opts.instanceId,
})
}
function recordSubscriberState(state: 'connected' | 'error' | 'reconnecting') {
opts.gatewayMetrics?.subscriberState.add(1, {
state,
@@ -76,10 +94,10 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
}
subscriber.on('message', (channel, message) => {
if (channel !== 'configkv:invalidate')
if (channel !== CONFIG_KV_INVALIDATION_CHANNEL)
return
try {
const payload = JSON.parse(message) as { key?: unknown }
const payload = parseConfigKVInvalidation(message)
// LLM_ROUTER_CONFIG drives a model-config cache + voice-catalog cache
// invalidation (key rotation, model add/remove, region swap all need to
// surface immediately). UNSPEECH_UPSTREAM only affects the voice catalog
@@ -116,7 +134,16 @@ export function createConfigSyncSubscriber(opts: ConfigSyncSubscriberOptions): C
// defaults to true.
subscriber.on('reconnecting', () => recordSubscriberState('reconnecting'))
subscriber.subscribe('configkv:invalidate')
// Pub/Sub does not replay messages. Clear the derived Redis entries and all
// local router state whenever this connection becomes ready so a reconnect
// cannot keep data that changed while the subscriber was offline.
subscriber.on('ready', () => {
void invalidateRouterState('reconnect').catch((err) => {
opts.logger.withError(err).warn('Failed to resync ConfigKV state after subscriber reconnect')
})
})
subscriber.subscribe(CONFIG_KV_INVALIDATION_CHANNEL)
.then(() => recordSubscriberState('connected'))
.catch((err: unknown) => {
opts.logger.withError(err).warn('Failed to subscribe to configkv:invalidate channel')
@@ -1,11 +1,11 @@
import type { InferOutput } from 'valibot'
// NOTICE:
// The Valibot schemas in `services/config-kv.ts` are the single source of
// The Valibot schemas in `services/adapters/config-kv/definitions.ts` are the single source of
// truth for the router config tree. We re-export inferred types so downstream
// modules don't redeclare the shape. New fields belong in config-kv.ts, not
// modules don't redeclare the shape. New fields belong in that file, not
// here.
// Source: server/apps/api/src/services/config-kv.ts (llmRouterConfigSchema).
// Source: server/apps/api/src/services/adapters/config-kv/definitions.ts (llmRouterConfigSchema).
import type {
asrModelSchema,
asrUpstreamSchema,
-4
View File
@@ -13,10 +13,6 @@ export function redisKeyFrom(...parts: RedisKeyPart[]): string {
}).join(':')
}
export function configRedisKey(key: string): string {
return redisKeyFrom('config', key)
}
export function userFluxRedisKey(userId: string): string {
return redisKeyFrom('user', userId, 'flux')
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
configRedisKey,
lockRedisKey,
redisKeyFrom,
userChatBroadcastRedisKey,
@@ -20,8 +19,7 @@ describe('redis key utils', () => {
expect(() => redisKeyFrom('user', ' ', 'flux')).toThrow('Redis key segments must not be empty')
})
it('exposes stable helpers for config, user, and lock namespaces', () => {
expect(configRedisKey('FLUX_PER_REQUEST')).toBe('config:FLUX_PER_REQUEST')
it('exposes stable helpers for user and lock namespaces', () => {
expect(userFluxRedisKey('user-1')).toBe('user:user-1:flux')
expect(userChatBroadcastRedisKey('user-1')).toBe('user:user-1:chat:broadcast')
expect(userChatBroadcastRedisPattern()).toBe('user:*:chat:broadcast')
+6 -48
View File
@@ -1,5 +1,4 @@
import type { Context } from 'hono'
import type Redis from 'ioredis'
import type { RateLimitMetrics } from './otel'
import type { HonoEnv } from './routes'
@@ -7,46 +6,7 @@ import type { HonoEnv } from './routes'
import { isIP } from 'node:net'
import { getConnInfo } from '@hono/node-server/conninfo'
import { errorMessageFrom } from '@moeru/std'
import { rateLimiter as createRateLimiter } from 'hono-rate-limiter'
import { number, parse } from 'valibot'
import { createServiceUnavailableError } from './error'
export interface AuthRateLimitConfig {
max: number
windowSec: number
}
export function createAuthConfigService(redis: Redis) {
async function readNumber(key: 'AUTH_RATE_LIMIT_MAX' | 'AUTH_RATE_LIMIT_WINDOW_SEC', defaultValue: number): Promise<number> {
const raw = await redis.get(`config:${key}`)
if (raw === null)
return defaultValue
try {
return parse(number(), JSON.parse(raw))
}
catch (error) {
throw createServiceUnavailableError('Auth configuration is invalid', 'CONFIG_INVALID', {
key,
message: errorMessageFrom(error) ?? 'Unknown config parse error',
})
}
}
return {
async getRateLimit(): Promise<AuthRateLimitConfig> {
const [max, windowSec] = await Promise.all([
readNumber('AUTH_RATE_LIMIT_MAX', 20),
readNumber('AUTH_RATE_LIMIT_WINDOW_SEC', 60),
])
return { max, windowSec }
},
}
}
export type AuthConfigService = ReturnType<typeof createAuthConfigService>
interface RateLimitOptions {
/** Max requests allowed within the window */
@@ -92,9 +52,8 @@ export function rateLimiter(opts: RateLimitOptions) {
if (trustedProxyAddress)
return trustedProxyAddress
// `app.request()` and fetch-style deployments have no Node incoming
// socket. Keep those requests in a shared bucket rather than trusting a
// client-controlled forwarding header.
// app.request() and fetch-style deployments have no Node incoming
// socket. Keep them in one bucket instead of trusting client headers.
try {
const info = getConnInfo(c)
return info.remote?.address ?? 'anonymous'
@@ -107,14 +66,13 @@ 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.
// NOTICE: draft-6 keeps the widely supported RateLimit-* header set.
// Later drafts use combined formats that existing clients may not parse.
standardHeaders: 'draft-6',
keyGenerator: keyGen,
handler: (c) => {
// Record before producing the 429 response so the time series captures
// every block, even when the response shape later changes.
// Record the block before producing the response so later response
// changes cannot remove the metric.
const keyType = c.get('user')?.id ? 'user' : 'ip'
opts.metrics?.blocked.add(1, {
route: opts.routeLabel ?? 'unknown',
+4 -9
View File
@@ -4,7 +4,6 @@ import type { AuthInstance } from './auth'
import type { AuthDatabase } from './db'
import type { AuthEnv } from './env'
import type { RateLimitMetrics } from './otel'
import type { AuthConfigService } from './rate-limit'
import { createHash } from 'node:crypto'
@@ -259,7 +258,6 @@ export interface AuthRoutesDeps {
auth: AuthInstance
db: AuthDatabase
env: AuthEnv
authConfig: AuthConfigService
rateLimitMetrics?: RateLimitMetrics | null
}
@@ -272,8 +270,6 @@ export interface AuthRoutesDeps {
* (`/auth/*`, `/api/auth/*`, `/.well-known/*`).
*/
export async function createAuthRoutes(deps: AuthRoutesDeps) {
const rateLimitConfig = await deps.authConfig.getRateLimit()
async function handleAuthRequest(request: Request): Promise<Response> {
const response = await deps.auth.handler(request)
@@ -291,8 +287,8 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
* Rate limited by the Auth-owned runtime configuration.
*/
.use('/api/auth/*', rateLimiter({
max: rateLimitConfig.max,
windowSec: rateLimitConfig.windowSec,
max: 20,
windowSec: 60,
// Proxy trust is a deployment boundary, not a property of the public
// API URL. Custom domains and private gateways must opt in explicitly.
trustedProxy: deps.env.RATE_LIMIT_TRUSTED_PROXY,
@@ -361,9 +357,8 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
*
* Account-enumeration tradeoff: this confirms whether an email is
* registered, mirroring the standard set by Google/Linear/Notion. We
* accept the disclosure since the existing rate limiter applied to
* `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles
* enumeration attempts.
* accept the disclosure since the existing rate limiter applies a fixed
* per-IP request limit to `/api/auth/*` and throttles enumeration attempts.
*/
.on('POST', '/api/auth/check-email', async (c) => {
const body = await c.req.json().catch(() => null) as { email?: unknown } | null
+1 -10
View File
@@ -4,7 +4,6 @@ import type { AuthInstance } from './auth'
import type { AuthDatabase } from './db'
import type { AuthEnv } from './env'
import type { RateLimitMetrics } from './otel'
import type { AuthConfigService } from './rate-limit'
import type { HonoEnv } from './routes'
import process from 'node:process'
@@ -27,7 +26,6 @@ import { parseAuthEnv } from './env'
import { ApiError, createInternalError } from './error'
import { getTrustedOrigin } from './origin'
import { emitOtelLog, initAuthOtel } from './otel'
import { createAuthConfigService } from './rate-limit'
import { createResourceApi } from './resource-api'
import { createAuthRoutes } from './routes'
@@ -64,7 +62,6 @@ export interface AuthAppDeps {
db: AuthDatabase
redis: Redis
env: AuthEnv
authConfig: AuthConfigService
rateLimitMetrics?: RateLimitMetrics | null
}
@@ -136,7 +133,6 @@ export async function buildAuthApp(deps: AuthAppDeps) {
auth: deps.auth,
db: deps.db,
env: deps.env,
authConfig: deps.authConfig,
rateLimitMetrics: deps.rateLimitMetrics,
}))
@@ -206,10 +202,6 @@ export async function createAuthServer() {
return instance
},
})
const authConfig = provide(container, 'services:authConfig', {
dependsOn: { redis },
build: ({ dependsOn }) => createAuthConfigService(dependsOn.redis),
})
const email = provide(container, 'services:email', {
dependsOn: { env, otel },
build: ({ dependsOn }) => createEmailService({
@@ -244,14 +236,13 @@ export async function createAuthServer() {
})
await start(container)
const dependencies = await resolve(container, { auth, authConfig, db, redis, env, otel })
const dependencies = await resolve(container, { auth, db, redis, env, otel })
const { app } = await buildAuthApp({
auth: dependencies.auth,
db: dependencies.db,
redis: dependencies.redis,
env: dependencies.env,
authConfig: dependencies.authConfig,
rateLimitMetrics: dependencies.otel?.rateLimit,
})
-3
View File
@@ -23,9 +23,6 @@ function createTestDeps() {
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
authConfig: {
getRateLimit: vi.fn(async () => ({ max: 20, windowSec: 60 })),
} as any,
rateLimitMetrics: null,
}
}
+11 -43
View File
@@ -1,60 +1,24 @@
import type Redis from 'ioredis'
import type { AuthConfigService } from '../rate-limit'
import type { HonoEnv } from '../routes'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { createAuthConfigService } from '../rate-limit'
import { createAuthRoutes } from '../routes'
function createRedis(values: Record<string, string | null>): Redis {
return {
get: vi.fn(async (key: string) => values[key] ?? null),
} as unknown as Redis
}
describe('auth rate-limit config', () => {
it('uses defaults when Redis keys are absent', async () => {
expect(await createAuthConfigService(createRedis({})).getRateLimit()).toEqual({ max: 20, windowSec: 60 })
})
it('reads rate-limit values from the shared ConfigKV namespace', async () => {
const service = createAuthConfigService(createRedis({
'config:AUTH_RATE_LIMIT_MAX': '40',
'config:AUTH_RATE_LIMIT_WINDOW_SEC': '120',
}))
expect(await service.getRateLimit()).toEqual({ max: 40, windowSec: 120 })
})
it('rejects malformed stored values', async () => {
const service = createAuthConfigService(createRedis({ 'config:AUTH_RATE_LIMIT_MAX': '"forty"' }))
await expect(service.getRateLimit()).rejects.toMatchObject({ errorCode: 'CONFIG_INVALID' })
})
})
function createAuthConfig(): AuthConfigService {
return {
getRateLimit: vi.fn(async () => ({ max: 1, windowSec: 60 })),
}
}
async function createApp(trustedProxy?: 'railway') {
const routes = await createAuthRoutes({
auth: {
handler: vi.fn(async () => new Response(null, { status: 200 })),
api: { getSession: vi.fn(async () => null) },
} as any,
db: {} as any,
} as unknown as Parameters<typeof createAuthRoutes>[0]['auth'],
db: {} as unknown as Parameters<typeof createAuthRoutes>[0]['db'],
env: {
PUBLIC_URL: 'https://api.airi.build',
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
RATE_LIMIT_TRUSTED_PROXY: trustedProxy,
} as any,
authConfig: createAuthConfig(),
} as unknown as Parameters<typeof createAuthRoutes>[0]['env'],
rateLimitMetrics: null,
})
@@ -89,11 +53,13 @@ function request(origin: string, clientAddress: string) {
}
describe('auth API rate limiting behind Railway', () => {
it('ignores forwarded client IPs unless proxy trust is explicitly enabled', async () => {
it('uses one fixed 20-request bucket when proxy trust is disabled', async () => {
const server = await listen(await createApp())
try {
expect((await request(server.origin, '203.0.113.20')).status).toBe(200)
for (let index = 0; index < 20; index += 1)
expect((await request(server.origin, `203.0.113.${index + 1}`)).status).toBe(200)
expect((await request(server.origin, '203.0.113.21')).status).toBe(429)
}
finally {
@@ -111,9 +77,11 @@ describe('auth API rate limiting behind Railway', () => {
const server = await listen(await createApp('railway'), '::1')
try {
expect((await request(server.origin, '203.0.113.10')).status).toBe(200)
for (let index = 0; index < 20; index += 1)
expect((await request(server.origin, '203.0.113.10')).status).toBe(200)
expect((await request(server.origin, '203.0.113.10')).status).toBe(429)
expect((await request(server.origin, '203.0.113.11')).status).toBe(200)
expect((await request(server.origin, '203.0.113.11')).status).toBe(429)
}
finally {
await server.close()
@@ -1,4 +1,3 @@
import type { AuthConfigService } from '../rate-limit'
import type { AuthRoutesDeps, HonoEnv } from '../routes'
import { Hono } from 'hono'
@@ -12,12 +11,6 @@ import { createAuthRoutes } from '../routes'
// flag lives on the user row (better-auth admin plugin), so we drive it via the
// mocked session — no DB query happens on this path.
function createAuthConfig(): AuthConfigService {
return {
getRateLimit: vi.fn(async () => ({ max: 100, windowSec: 60 })),
}
}
interface SessionUser { id: string, email: string, banned: boolean, banExpires: Date | null }
function sessionFor(user: SessionUser) {
@@ -41,7 +34,6 @@ async function buildRoutes(currentUser: SessionUser) {
AUTH_UI_URL: 'https://accounts.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
authConfig: createAuthConfig(),
rateLimitMetrics: null,
}