From 88625a8d842d8f32205a414fef296cc8166a7d56 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sat, 15 Aug 2026 22:24:32 +0800 Subject: [PATCH] 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 ` (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. ## 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. --------- Signed-off-by: RainbowBird Signed-off-by: RainbowBird --- server/AGENTS.md | 11 + .../api/drizzle/0020_smart_war_machine.sql | 5 + .../apps/api/drizzle/meta/0020_snapshot.json | 3697 +++++++++++++++++ server/apps/api/drizzle/meta/_journal.json | 7 + server/apps/api/src/app.ts | 6 +- server/apps/api/src/schemas/config-kv.ts | 8 + server/apps/api/src/schemas/index.ts | 1 + .../adapters/config-kv/contracts.test.ts | 26 + .../services/adapters/config-kv/contracts.ts | 43 + .../definitions.ts} | 86 +- .../index.test.ts} | 87 +- .../src/services/adapters/config-kv/index.ts | 96 + .../services/adapters/config-kv/store.test.ts | 93 + .../src/services/adapters/config-kv/store.ts | 78 + .../llm-router/config-sync-subscriber.test.ts | 96 + .../llm-router/config-sync-subscriber.ts | 33 +- .../src/services/domain/llm-router/types.ts | 6 +- server/apps/api/src/utils/redis-keys.ts | 4 - .../api/src/utils/tests/redis-keys.test.ts | 4 +- server/apps/auth/src/rate-limit.ts | 54 +- server/apps/auth/src/routes.ts | 13 +- server/apps/auth/src/server.ts | 11 +- server/apps/auth/src/tests/app.test.ts | 3 - server/apps/auth/src/tests/rate-limit.test.ts | 54 +- .../auth/src/tests/routes-userinfo.test.ts | 8 - 25 files changed, 4269 insertions(+), 261 deletions(-) create mode 100644 server/AGENTS.md create mode 100644 server/apps/api/drizzle/0020_smart_war_machine.sql create mode 100644 server/apps/api/drizzle/meta/0020_snapshot.json create mode 100644 server/apps/api/src/schemas/config-kv.ts create mode 100644 server/apps/api/src/services/adapters/config-kv/contracts.test.ts create mode 100644 server/apps/api/src/services/adapters/config-kv/contracts.ts rename server/apps/api/src/services/adapters/{config-kv.ts => config-kv/definitions.ts} (81%) rename server/apps/api/src/services/adapters/{config-kv.test.ts => config-kv/index.test.ts} (76%) create mode 100644 server/apps/api/src/services/adapters/config-kv/index.ts create mode 100644 server/apps/api/src/services/adapters/config-kv/store.test.ts create mode 100644 server/apps/api/src/services/adapters/config-kv/store.ts create mode 100644 server/apps/api/src/services/domain/llm-router/config-sync-subscriber.test.ts diff --git a/server/AGENTS.md b/server/AGENTS.md new file mode 100644 index 000000000..4e7ea17e5 --- /dev/null +++ b/server/AGENTS.md @@ -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`, or type casts as runtime input validation. +- Infer TypeScript types from Valibot schemas. Do not duplicate the contract in an interface. diff --git a/server/apps/api/drizzle/0020_smart_war_machine.sql b/server/apps/api/drizzle/0020_smart_war_machine.sql new file mode 100644 index 000000000..dc6a970b9 --- /dev/null +++ b/server/apps/api/drizzle/0020_smart_war_machine.sql @@ -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 +); diff --git a/server/apps/api/drizzle/meta/0020_snapshot.json b/server/apps/api/drizzle/meta/0020_snapshot.json new file mode 100644 index 000000000..e4ac83e0f --- /dev/null +++ b/server/apps/api/drizzle/meta/0020_snapshot.json @@ -0,0 +1,3697 @@ +{ + "id": "9e39448c-8e94-443e-9a53-02b34c273111", + "prevId": "3f4775b5-cba4-44d9-865e-0d50bfcc9f56", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_members_user_id_member_type_chat_id_idx": { + "name": "chat_members_user_id_member_type_chat_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_members_chat_id_member_type_user_id_idx": { + "name": "chat_members_chat_id_member_type_user_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_chat_id_seq_idx": { + "name": "messages_chat_id_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_chat_id_seq_active_idx": { + "name": "messages_chat_id_seq_active_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_events": { + "name": "product_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_events_feature_action_created_at_idx": { + "name": "product_events_feature_action_created_at_idx", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_user_id_created_at_idx": { + "name": "product_events_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_created_at_idx": { + "name": "product_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capability_alias_routes": { + "name": "capability_alias_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pool": { + "name": "pool", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "capability_alias_routes_alias_model_pool_uidx": { + "name": "capability_alias_routes_alias_model_pool_uidx", + "columns": [ + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pool", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "capability_alias_routes_alias_id_capability_aliases_id_fk": { + "name": "capability_alias_routes_alias_id_capability_aliases_id_fk", + "tableFrom": "capability_alias_routes", + "tableTo": "capability_aliases", + "columnsFrom": [ + "alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.capability_aliases": { + "name": "capability_aliases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alias_id": { + "name": "alias_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fallback_enabled": { + "name": "fallback_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "load_balancing_enabled": { + "name": "load_balancing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "capability_aliases_surface_alias_uidx": { + "name": "capability_aliases_surface_alias_uidx", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog_tts_models": { + "name": "provider_catalog_tts_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "router_model_id": { + "name": "router_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_catalog_tts_models_router_model_uidx": { + "name": "provider_catalog_tts_models_router_model_uidx", + "columns": [ + { + "expression": "router_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog_tts_voices": { + "name": "provider_catalog_tts_voices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_voice_id": { + "name": "provider_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "preview_audio_url": { + "name": "preview_audio_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'provider-sync'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_catalog_tts_voices_model_voice_uidx": { + "name": "provider_catalog_tts_voices_model_voice_uidx", + "columns": [ + { + "expression": "tts_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_voice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk": { + "name": "provider_catalog_tts_voices_tts_model_id_provider_catalog_tts_models_id_fk", + "tableFrom": "provider_catalog_tts_voices", + "tableTo": "provider_catalog_tts_models", + "columnsFrom": [ + "tts_model_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_packs": { + "name": "voice_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voice_id": { + "name": "voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_voice_id": { + "name": "upstream_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_provider_id_idx": { + "name": "account_account_id_provider_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_token_idx": { + "name": "oauth_refresh_token_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_expires_at_idx": { + "name": "session_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.config_kv": { + "name": "config_kv", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/apps/api/drizzle/meta/_journal.json b/server/apps/api/drizzle/meta/_journal.json index 0d0437d0f..02f8801b9 100644 --- a/server/apps/api/drizzle/meta/_journal.json +++ b/server/apps/api/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/server/apps/api/src/app.ts b/server/apps/api/src/app.ts index 3e67752c5..93c0a4131 100644 --- a/server/apps/api/src/app.ts +++ b/server/apps/api/src/app.ts @@ -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', { diff --git a/server/apps/api/src/schemas/config-kv.ts b/server/apps/api/src/schemas/config-kv.ts new file mode 100644 index 000000000..b2134a803 --- /dev/null +++ b/server/apps/api/src/schemas/config-kv.ts @@ -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(), +}) diff --git a/server/apps/api/src/schemas/index.ts b/server/apps/api/src/schemas/index.ts index 71419e4a3..a3db0119d 100644 --- a/server/apps/api/src/schemas/index.ts +++ b/server/apps/api/src/schemas/index.ts @@ -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' diff --git a/server/apps/api/src/services/adapters/config-kv/contracts.test.ts b/server/apps/api/src/services/adapters/config-kv/contracts.test.ts new file mode 100644 index 000000000..42537e672 --- /dev/null +++ b/server/apps/api/src/services/adapters/config-kv/contracts.test.ts @@ -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') + }) +}) diff --git a/server/apps/api/src/services/adapters/config-kv/contracts.ts b/server/apps/api/src/services/adapters/config-kv/contracts.ts new file mode 100644 index 000000000..9540abb98 --- /dev/null +++ b/server/apps/api/src/services/adapters/config-kv/contracts.ts @@ -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 + +/** 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) +} diff --git a/server/apps/api/src/services/adapters/config-kv.ts b/server/apps/api/src/services/adapters/config-kv/definitions.ts similarity index 81% rename from server/apps/api/src/services/adapters/config-kv.ts rename to server/apps/api/src/services/adapters/config-kv/definitions.ts index 714d2d46d..f5c670f1c 100644 --- a/server/apps/api/src/services/adapters/config-kv.ts +++ b/server/apps/api/src/services/adapters/config-kv/definitions.ts @@ -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(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(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(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(key: K): Promise { - const raw = await redis.get(configRedisKey(key)) - const value = resolveWithDefault(key, raw) - return value ?? null - }, - - async getOrThrow(key: K): Promise> { - 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 - }, - - async get(key: K): Promise> { - return this.getOrThrow(key) - }, - - async set(key: K, value: ConfigDefinitions[K]): Promise { - const serialized = serializeValue(key, value) - await redis.set(configRedisKey(key), serialized) - }, - } -} - -export type ConfigKVService = ReturnType +export type ConfigKey = keyof ConfigDefinitions diff --git a/server/apps/api/src/services/adapters/config-kv.test.ts b/server/apps/api/src/services/adapters/config-kv/index.test.ts similarity index 76% rename from server/apps/api/src/services/adapters/config-kv.test.ts rename to server/apps/api/src/services/adapters/config-kv/index.test.ts index c2643532d..2d341c268 100644 --- a/server/apps/api/src/services/adapters/config-kv.test.ts +++ b/server/apps/api/src/services/adapters/config-kv/index.test.ts @@ -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() 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 + let store: ReturnType let service: ReturnType 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>() }) 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() }) }) diff --git a/server/apps/api/src/services/adapters/config-kv/index.ts b/server/apps/api/src/services/adapters/config-kv/index.ts new file mode 100644 index 000000000..e3c9050e9 --- /dev/null +++ b/server/apps/api/src/services/adapters/config-kv/index.ts @@ -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(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(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 { + 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(key: K): Promise { + const raw = await loadRaw(key) + const value = resolveWithDefault(key, raw) + return value ?? null + }, + + async getOrThrow(key: K): Promise> { + 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 + }, + + async get(key: K): Promise> { + return this.getOrThrow(key) + }, + + async refresh(key: K): Promise { + const raw = await loadRaw(key, true) + const value = resolveWithDefault(key, raw) + return value ?? null + }, + + async invalidateCache(key: K): Promise { + await store.invalidateCache(key) + }, + } +} + +export type ConfigKVService = ReturnType diff --git a/server/apps/api/src/services/adapters/config-kv/store.test.ts b/server/apps/api/src/services/adapters/config-kv/store.test.ts new file mode 100644 index 000000000..1531f6834 --- /dev/null +++ b/server/apps/api/src/services/adapters/config-kv/store.test.ts @@ -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> + + 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() + }) +}) diff --git a/server/apps/api/src/services/adapters/config-kv/store.ts b/server/apps/api/src/services/adapters/config-kv/store.ts new file mode 100644 index 000000000..b20ff2654 --- /dev/null +++ b/server/apps/api/src/services/adapters/config-kv/store.ts @@ -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>( + db: NodePgDatabase, + redis: Redis, + options: ConfigKVStoreOptions = {}, +) { + const cacheTtlSeconds = options.cacheTtlSeconds ?? CONFIG_KV_CACHE_TTL_SECONDS + + async function readDatabase(key: ConfigKey): Promise { + 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 { + await redis.set(configKVCacheKey(key), value, 'EX', cacheTtlSeconds) + } + + async function deleteCachedValue(key: ConfigKey): Promise { + await redis.del(configKVCacheKey(key)) + } + + return { + async getRaw(key: ConfigKey): Promise { + 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 { + const value = await readDatabase(key) + if (value !== null) { + await cacheValue(key, value) + } + else { + await deleteCachedValue(key) + } + return value + }, + + async invalidateCache(key: ConfigKey): Promise { + await deleteCachedValue(key) + }, + } +} + +export type ConfigKVStore = ReturnType diff --git a/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.test.ts b/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.test.ts new file mode 100644 index 000000000..630be8816 --- /dev/null +++ b/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.test.ts @@ -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): Promise { + 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, key: string): Promise { + await harness.subscriber.subscribe(CONFIG_KV_INVALIDATION_CHANNEL) + const received = new Promise((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') + }) +}) diff --git a/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.ts b/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.ts index 6c45df17f..e3d90926a 100644 --- a/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.ts +++ b/server/apps/api/src/services/domain/llm-router/config-sync-subscriber.ts @@ -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 /** 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 { + 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') diff --git a/server/apps/api/src/services/domain/llm-router/types.ts b/server/apps/api/src/services/domain/llm-router/types.ts index 404025483..280e4d11a 100644 --- a/server/apps/api/src/services/domain/llm-router/types.ts +++ b/server/apps/api/src/services/domain/llm-router/types.ts @@ -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, diff --git a/server/apps/api/src/utils/redis-keys.ts b/server/apps/api/src/utils/redis-keys.ts index 5146ebb34..0bbf561cd 100644 --- a/server/apps/api/src/utils/redis-keys.ts +++ b/server/apps/api/src/utils/redis-keys.ts @@ -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') } diff --git a/server/apps/api/src/utils/tests/redis-keys.test.ts b/server/apps/api/src/utils/tests/redis-keys.test.ts index e27147eff..5ee8cf37b 100644 --- a/server/apps/api/src/utils/tests/redis-keys.test.ts +++ b/server/apps/api/src/utils/tests/redis-keys.test.ts @@ -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') diff --git a/server/apps/auth/src/rate-limit.ts b/server/apps/auth/src/rate-limit.ts index f67c458eb..ef198d991 100644 --- a/server/apps/auth/src/rate-limit.ts +++ b/server/apps/auth/src/rate-limit.ts @@ -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 { - 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 { - 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 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({ 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', diff --git a/server/apps/auth/src/routes.ts b/server/apps/auth/src/routes.ts index 75330f3e3..f9b088d75 100644 --- a/server/apps/auth/src/routes.ts +++ b/server/apps/auth/src/routes.ts @@ -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 { 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 diff --git a/server/apps/auth/src/server.ts b/server/apps/auth/src/server.ts index ab427ab77..adfb4a035 100644 --- a/server/apps/auth/src/server.ts +++ b/server/apps/auth/src/server.ts @@ -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, }) diff --git a/server/apps/auth/src/tests/app.test.ts b/server/apps/auth/src/tests/app.test.ts index 685b8030e..f8952f7ed 100644 --- a/server/apps/auth/src/tests/app.test.ts +++ b/server/apps/auth/src/tests/app.test.ts @@ -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, } } diff --git a/server/apps/auth/src/tests/rate-limit.test.ts b/server/apps/auth/src/tests/rate-limit.test.ts index 02baf7a75..ca34afd60 100644 --- a/server/apps/auth/src/tests/rate-limit.test.ts +++ b/server/apps/auth/src/tests/rate-limit.test.ts @@ -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): 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[0]['auth'], + db: {} as unknown as Parameters[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[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() diff --git a/server/apps/auth/src/tests/routes-userinfo.test.ts b/server/apps/auth/src/tests/routes-userinfo.test.ts index 3516f5b5f..b3e2a9782 100644 --- a/server/apps/auth/src/tests/routes-userinfo.test.ts +++ b/server/apps/auth/src/tests/routes-userinfo.test.ts @@ -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, }