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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
+6
-80
@@ -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
|
||||
+42
-45
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user