diff --git a/apps/server/.env b/apps/server/.env index f403c2c33..d454b8fcc 100644 --- a/apps/server/.env +++ b/apps/server/.env @@ -6,4 +6,13 @@ AUTH_GOOGLE_CLIENT_SECRET="change-me" AUTH_GITHUB_CLIENT_ID="change-me" AUTH_GITHUB_CLIENT_SECRET="change-me" -# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" +STRIPE_SECRET_KEY="change-me" +STRIPE_WEBHOOK_SECRET="change-me" + +BACKEND_LLM_API_KEY="change-me" +BACKEND_LLM_BASE_URL="change-me" + +CLIENT_URL="change-me" + +FLUX_PER_CENT=1 +FLUX_PER_REQUEST=1 diff --git a/apps/server/drizzle/0001_magenta_skrulls.sql b/apps/server/drizzle/0001_magenta_skrulls.sql new file mode 100644 index 000000000..6957e9899 --- /dev/null +++ b/apps/server/drizzle/0001_magenta_skrulls.sql @@ -0,0 +1,8 @@ +CREATE TABLE "user_flux" ( + "user_id" text PRIMARY KEY NOT NULL, + "flux" integer DEFAULT 0 NOT NULL, + "stripe_customer_id" text, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_flux" ADD CONSTRAINT "user_flux_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..343b01772 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1563 @@ +{ + "id": "4d4b5a3a-6b6d-4015-ac4b-fa1d434ff3f8", + "prevId": "1b7233f1-a113-4982-afdd-f560fd4d1c2f", + "version": "7", + "dialect": "postgresql", + "tables": { + "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": {} + } + }, + "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.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 + } + }, + "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": {} + } + }, + "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 + }, + "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.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": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": {}, + "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": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": {}, + "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.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "integer", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_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": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 9f63c6fcf..17bdb9fa1 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772532674887, "tag": "0000_mean_slipstream", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772532802115, + "tag": "0001_magenta_skrulls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index d612bf8cc..ce0176367 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,8 @@ "hono": "catalog:", "injeca": "catalog:", "pg": "^8.20.0", + "postgres": "^3.4.8", + "stripe": "catalog:", "tsx": "^4.21.0", "valibot": "catalog:" }, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 5844e8e22..996a2384f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,4 @@ +import type { Env } from './libs/env' import type { HonoEnv } from './types/hono' import process from 'node:process' @@ -18,9 +19,13 @@ import { sessionMiddleware } from './middlewares/auth' import { otelMiddleware } from './middlewares/otel' import { createCharacterRoutes } from './routes/characters' import { createChatRoutes } from './routes/chats' +import { createFluxRoutes } from './routes/flux' import { createProviderRoutes } from './routes/providers' +import { createStripeRoutes } from './routes/stripe' +import { createV1CompletionsRoutes } from './routes/v1completions' import { createCharacterService } from './services/characters' import { createChatService } from './services/chats' +import { createFluxService } from './services/flux' import { createProviderService } from './services/providers' import { ApiError, createInternalError } from './utils/error' import { getTrustedOrigin } from './utils/origin' @@ -29,6 +34,7 @@ type AuthService = ReturnType type CharacterService = ReturnType type ChatService = ReturnType type ProviderService = ReturnType +type FluxService = ReturnType type OtelMetrics = ReturnType @@ -37,10 +43,11 @@ interface AppDeps { characterService: CharacterService chatService: ChatService providerService: ProviderService - otel: OtelMetrics | null + fluxService: FluxService + env: Env } -function buildApp({ auth, characterService, chatService, providerService, otel }: AppDeps) { +function buildApp({ auth, characterService, chatService, providerService, fluxService, env }: AppDeps) { const logger = useLogger('app').useGlobalConfig() const app = new Hono() @@ -104,6 +111,21 @@ function buildApp({ auth, characterService, chatService, providerService, otel } * Chat routes are handled by the chat service. */ .route('/api/chats', createChatRoutes(chatService)) + + /** + * V1 routes for official provider. + */ + .route('/v1', createV1CompletionsRoutes(fluxService, env)) + + /** + * Flux routes. + */ + .route('/api/flux', createFluxRoutes(fluxService)) + + /** + * Stripe routes. + */ + .route('/api/stripe', createStripeRoutes(fluxService, env)) } export type AppType = ReturnType @@ -159,14 +181,20 @@ async function createApp() { build: ({ dependsOn }) => createChatService(dependsOn.db), }) + const fluxService = injeca.provide('services:flux', { + dependsOn: { db }, + build: ({ dependsOn }) => createFluxService(dependsOn.db), + }) + await injeca.start() - const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, otel }) + const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, env: parsedEnv }) const app = buildApp({ auth: resolved.auth, characterService: resolved.characterService, chatService: resolved.chatService, providerService: resolved.providerService, - otel: resolved.otel, + fluxService: resolved.fluxService, + env: resolved.env, }) logger.withFields({ port: 3000 }).log('Server started') diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index a16763fb0..521140190 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -4,7 +4,7 @@ import { env, exit } from 'node:process' import { useLogger } from '@guiiai/logg' import { injeca } from 'injeca' -import { nonEmpty, object, optional, parse, pipe, string } from 'valibot' +import { nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' const EnvSchema = object({ API_SERVER_URL: optional(string(), 'http://localhost:3000'), @@ -16,13 +16,15 @@ const EnvSchema = object({ AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')), AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')), - // OpenTelemetry - OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'), - OTEL_SERVICE_NAME: optional(string(), 'server'), - OTEL_TRACES_SAMPLING_RATIO: optional(string(), '1.0'), - OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()), - OTEL_EXPORTER_OTLP_HEADERS: optional(string()), - OTEL_DEBUG: optional(string()), + STRIPE_SECRET_KEY: optional(string()), + STRIPE_WEBHOOK_SECRET: optional(string()), + CLIENT_URL: pipe(string(), nonEmpty('CLIENT_URL is required')), + + FLUX_PER_CENT: optional(pipe(string(), transform(Number)), '1'), + FLUX_PER_REQUEST: optional(pipe(string(), transform(Number)), '1'), + + BACKEND_LLM_API_KEY: pipe(string(), nonEmpty('BACKEND_LLM_API_KEY is required')), + BACKEND_LLM_BASE_URL: pipe(string(), nonEmpty('BACKEND_LLM_BASE_URL is required')), }) export type Env = InferOutput diff --git a/apps/server/src/routes/flux.ts b/apps/server/src/routes/flux.ts new file mode 100644 index 000000000..1feae6092 --- /dev/null +++ b/apps/server/src/routes/flux.ts @@ -0,0 +1,16 @@ +import type { FluxService } from '../services/flux' +import type { HonoEnv } from '../types/hono' + +import { Hono } from 'hono' + +import { authGuard } from '../middlewares/auth' + +export function createFluxRoutes(fluxService: FluxService) { + return new Hono() + .use('*', authGuard) + .get('/', async (c) => { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + return c.json(flux) + }) +} diff --git a/apps/server/src/routes/stripe.ts b/apps/server/src/routes/stripe.ts new file mode 100644 index 000000000..92b9ac1a3 --- /dev/null +++ b/apps/server/src/routes/stripe.ts @@ -0,0 +1,92 @@ +import type { Env } from '../libs/env' +import type { FluxService } from '../services/flux' +import type { HonoEnv } from '../types/hono' + +import Stripe from 'stripe' + +import { Hono } from 'hono' +import { integer, minValue, number, object, pipe, safeParse } from 'valibot' + +import { authGuard } from '../middlewares/auth' +import { ApiError, createBadRequestError } from '../utils/error' + +const CheckoutBodySchema = object({ + amount: pipe(number(), integer(), minValue(1)), +}) + +export function createStripeRoutes(fluxService: FluxService, env: Env) { + const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null + + return new Hono() + .post('/checkout', authGuard, async (c) => { + if (!stripe) + throw new ApiError(503, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured') + + const user = c.get('user')! + const body = await c.req.json() + + const result = safeParse(CheckoutBodySchema, body) + if (!result.success) + throw createBadRequestError('Invalid checkout amount', 'INVALID_REQUEST', result.issues) + + const { amount } = result.output + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'usd', + product_data: { + name: 'Flux Top-up', + }, + unit_amount: amount, + }, + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${env.CLIENT_URL}/settings/flux?success=true`, + cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`, + customer_email: user.email, + metadata: { + userId: user.id, + }, + }) + + return c.json({ url: session.url }) + }) + .post('/webhook', async (c) => { + if (!stripe || !env.STRIPE_WEBHOOK_SECRET) + throw new ApiError(503, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured') + + const sig = c.req.header('stripe-signature') + if (!sig) + return c.json({ error: 'No signature' }, 400) + + let event: Stripe.Event + try { + const body = await c.req.text() + event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET) + } + catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Unknown error' + return c.json({ error: `Webhook Error: ${message}` }, 400) + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session + const userId = session.metadata?.userId + const amount = session.amount_total + + if (userId && amount) { + await fluxService.addFlux(userId, amount * env.FLUX_PER_CENT) + + if (typeof session.customer === 'string') + await fluxService.updateStripeCustomerId(userId, session.customer) + } + } + + return c.json({ received: true }) + }) +} diff --git a/apps/server/src/routes/v1completions.ts b/apps/server/src/routes/v1completions.ts new file mode 100644 index 000000000..1cfec9c5a --- /dev/null +++ b/apps/server/src/routes/v1completions.ts @@ -0,0 +1,56 @@ +import type { Context } from 'hono' + +import type { Env } from '../libs/env' +import type { FluxService } from '../services/flux' +import type { HonoEnv } from '../types/hono' + +import { Hono } from 'hono' + +import { authGuard } from '../middlewares/auth' + +// Only forward these headers from the upstream LLM response +const SAFE_RESPONSE_HEADERS = new Set([ + 'content-type', + 'content-length', + 'transfer-encoding', + 'cache-control', +]) + +export function createV1CompletionsRoutes(fluxService: FluxService, env: Env) { + async function handleCompletion(c: Context) { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + if (flux.flux <= 0) { + return c.json({ error: 'Insufficient flux' }, 402) + } + + const body = await c.req.json() + + await fluxService.consumeFlux(user.id, env.FLUX_PER_REQUEST) + + const response = await fetch(`${env.BACKEND_LLM_BASE_URL}chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.BACKEND_LLM_API_KEY}`, + }, + body: JSON.stringify(body), + }) + + const headers = new Headers() + for (const [key, value] of response.headers) { + if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase())) + headers.set(key, value) + } + + return new Response(response.body, { + status: response.status, + headers, + }) + } + + return new Hono() + .use('*', authGuard) + .post('/chat/completions', handleCompletion) + .post('/chat/completion', handleCompletion) +} diff --git a/apps/server/src/schemas/flux.ts b/apps/server/src/schemas/flux.ts new file mode 100644 index 000000000..3c11362c1 --- /dev/null +++ b/apps/server/src/schemas/flux.ts @@ -0,0 +1,10 @@ +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' + +import { user } from './accounts' + +export const userFlux = pgTable('user_flux', { + userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), + flux: integer('flux').notNull().default(0), + stripeCustomerId: text('stripe_customer_id'), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}) diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 9fd915e37..06f004372 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -1,5 +1,6 @@ export * from './accounts' export * from './characters' export * from './chats' +export * from './flux' export * from './providers' export * from './user-character' diff --git a/apps/server/src/services/__test__/flux.test.ts b/apps/server/src/services/__test__/flux.test.ts new file mode 100644 index 000000000..53169d02f --- /dev/null +++ b/apps/server/src/services/__test__/flux.test.ts @@ -0,0 +1,155 @@ +import { beforeAll, describe, expect, it } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createFluxService } from '../flux' + +import * as schema from '../../schemas' + +describe('fluxService', () => { + let db: any + let service: ReturnType + let testUser: any + + beforeAll(async () => { + db = await mockDB(schema) + service = createFluxService(db) + + // Create a test user for foreign key constraints + const [user] = await db.insert(schema.user).values({ + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + }).returning() + testUser = user + }) + + // --- getFlux --- + + it('getFlux should create a new record with 100 default flux for a new user', async () => { + const record = await service.getFlux(testUser.id) + + expect(record).toBeDefined() + expect(record.userId).toBe(testUser.id) + expect(record.flux).toBe(100) + }) + + it('getFlux should return existing record on subsequent calls', async () => { + const first = await service.getFlux(testUser.id) + const second = await service.getFlux(testUser.id) + + // Same record, no duplicate insert + expect(second.userId).toBe(first.userId) + expect(second.flux).toBe(first.flux) + }) + + // --- consumeFlux --- + + it('consumeFlux should deduct flux correctly', async () => { + const result = await service.consumeFlux(testUser.id, 10) + + // Started at 100, consumed 10 + expect(result.flux).toBe(90) + }) + + it('consumeFlux should throw when balance is insufficient', async () => { + // Current balance is 90 after previous test; consuming 91 should fail + await expect(service.consumeFlux(testUser.id, 91)) + .rejects + .toThrow('Insufficient flux') + }) + + it('consumeFlux should throw when trying to consume more than available', async () => { + await expect(service.consumeFlux(testUser.id, 999)) + .rejects + .toThrow('Insufficient flux') + }) + + // --- addFlux --- + + it('addFlux should add flux correctly', async () => { + // Balance is 90 from previous consume test + const result = await service.addFlux(testUser.id, 50) + expect(result.flux).toBe(140) + }) + + it('addFlux should accumulate across multiple calls', async () => { + // Balance is 140; add 10 three times + await service.addFlux(testUser.id, 10) + await service.addFlux(testUser.id, 10) + const result = await service.addFlux(testUser.id, 10) + + expect(result.flux).toBe(170) + }) + + // --- updateStripeCustomerId --- + + it('updateStripeCustomerId should update the stripe customer ID', async () => { + const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123') + + expect(result.stripeCustomerId).toBe('cus_abc123') + + // Verify it persists via getFlux + const record = await service.getFlux(testUser.id) + expect(record.stripeCustomerId).toBe('cus_abc123') + }) + + // --- Concurrent consumeFlux --- + + it('concurrent consumeFlux should not over-deduct flux', async () => { + // Set up a fresh user to isolate this test from previous state + const [user2] = await db.insert(schema.user).values({ + id: 'user-concurrent-consume', + name: 'Concurrent Consumer', + email: 'concurrent-consume@example.com', + }).returning() + + // Initialize flux record (100 default) + await service.getFlux(user2.id) + + // Fire 10 concurrent consume calls of 10 each (total 100, exactly the balance) + const results = await Promise.allSettled( + Array.from({ length: 10 }, () => service.consumeFlux(user2.id, 10)), + ) + + const fulfilled = results.filter(r => r.status === 'fulfilled') + const rejected = results.filter(r => r.status === 'rejected') + + // All 10 should succeed since total equals balance, but under concurrency + // some may fail if the atomic check-and-deduct fires after balance drops. + // The key invariant: final balance must never go negative. + const finalRecord = await service.getFlux(user2.id) + expect(finalRecord.flux).toBeGreaterThanOrEqual(0) + + // Total consumed must equal (fulfilled count * 10) + expect(finalRecord.flux).toBe(100 - fulfilled.length * 10) + + // Every rejection should be 'Insufficient flux' + for (const r of rejected) { + expect((r as PromiseRejectedResult).reason.message).toBe('Insufficient flux') + } + }) + + // --- Concurrent addFlux --- + + it('concurrent addFlux should accumulate correctly without lost updates', async () => { + // Set up a fresh user to isolate this test + const [user3] = await db.insert(schema.user).values({ + id: 'user-concurrent-add', + name: 'Concurrent Adder', + email: 'concurrent-add@example.com', + }).returning() + + // Initialize flux record (100 default) + await service.getFlux(user3.id) + + // Fire 10 concurrent add calls of 5 each (expect +50 total) + await Promise.all( + Array.from({ length: 10 }, () => service.addFlux(user3.id, 5)), + ) + + const finalRecord = await service.getFlux(user3.id) + + // 100 initial + 10 * 5 = 150 + expect(finalRecord.flux).toBe(150) + }) +}) diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts new file mode 100644 index 000000000..dff952a3c --- /dev/null +++ b/apps/server/src/services/flux.ts @@ -0,0 +1,78 @@ +import type * as fullSchema from '../schemas' +import type { Database } from './db' + +import { and, eq, gte, sql } from 'drizzle-orm' + +import * as schema from '../schemas/flux' + +export function createFluxService(db: Database) { + return { + async getFlux(userId: string) { + let record = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, userId), + }) + + if (!record) { + [record] = await db.insert(schema.userFlux).values({ + userId, + flux: 100, // Default initial flux + }).returning() + } + + return record + }, + + async consumeFlux(userId: string, amount: number) { + // Ensure the user has a flux record + await this.getFlux(userId) + + // Atomic check-and-deduct to prevent race conditions + const result = await db.update(schema.userFlux) + .set({ + flux: sql`${schema.userFlux.flux} - ${amount}`, + updatedAt: new Date(), + }) + .where(and( + eq(schema.userFlux.userId, userId), + gte(schema.userFlux.flux, amount), + )) + .returning() + + if (result.length === 0) { + throw new Error('Insufficient flux') + } + + return result[0] + }, + + async addFlux(userId: string, amount: number) { + // Ensure the user has a flux record + await this.getFlux(userId) + + // Atomic addition to prevent race conditions + const [updated] = await db.update(schema.userFlux) + .set({ + flux: sql`${schema.userFlux.flux} + ${amount}`, + updatedAt: new Date(), + }) + .where(eq(schema.userFlux.userId, userId)) + .returning() + + return updated + }, + + async updateStripeCustomerId(userId: string, stripeCustomerId: string) { + const [updated] = await db.update(schema.userFlux) + .set({ + stripeCustomerId, + updatedAt: new Date(), + }) + .where(eq(schema.userFlux.userId, userId)) + .returning() + + return updated + }, + } +} + +export type FluxService = ReturnType diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 6f5e5da61..32fa27f94 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -31,6 +31,11 @@ dialogs: next: Next retry: Retry start: Let's do it! + loginPrompt: Login to use the official AIRI provider for the best experience. + loginAction: Login + localSetup: Configure Local Provider + flux: Flux + buyFlux: Charge Flux select-model: Choose model no-models: No available models no-models-help: >- @@ -477,9 +482,28 @@ pages: apply-and-restart: Save and restart stdio MCP messages: opened: Config file opened at {path} - restarted: >- - MCP servers restarted. Started {started}, failed {failed}, skipped - {skipped} + restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped} + flux: + title: Flux + buy: Charge + description: Current Flux + checkout: + success: Payment successful! Your Flux has been topped up. + canceled: Payment was canceled. + error: Something went wrong. Please try again later. + packages: + title: Flux Packages + buy: Charge + description: Flux packages to choose from. + amount_500: + label: 500 Flux + price: '$5' + amount_1000: + label: 1000 Flux + price: '$10' + amount_5000: + label: 5000 Flux + price: '$45' providers: explained: chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama. @@ -846,6 +870,9 @@ pages: browser-web-speech-api: description: Browser-native STT (requires Chrome/Edge/Safari) title: Web Speech API + official: + title: Official Provider + description: Official AI provider by AIRI. transcriptions: playground: title: Transcription Playground @@ -1012,6 +1039,8 @@ pages: open-devtools: title: Open Developer Tools button: Open + credits: + buy: Buy sections: section: general: diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 4045336dd..d20e2513c 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -30,6 +30,11 @@ dialogs: next: 下一步 retry: 重试 start: 开始吧! + loginPrompt: 登录以使用官方 AIRI 服务来源以获得最佳体验。 + loginAction: 登录 + localSetup: 配置本地服务来源 + flux: Flux + buyFlux: 充能 Flux select-model: 选择模型 no-models: 找不到可用模型 no-models-help: >- @@ -462,9 +467,26 @@ pages: open-config: 打开 mcp.json apply-and-restart: 保存并重启 stdio MCP messages: - opened: 配置文件已于{path}打开 - restarted: >- - MCP 服务已重新启动,启动了{started},未启动{failed},跳过{skipped} + opened: Config file opened at {path} + restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped} + flux: + title: Flux + buy: 充能 + description: 当前 Flux + checkout: + success: 支付成功!Flux 已充值。 + canceled: 支付已取消。 + error: 出了点问题,请稍后再试。 + packages: + amount_500: + label: 500 Flux + price: '$5' + amount_1000: + label: 1000 Flux + price: '$10' + amount_5000: + label: 5000 Flux + price: '$45' providers: explained: chat: 文本生成模型服务来源,例如 OpenRouter, OpenAI, Ollama diff --git a/packages/stage-layouts/src/components/Layouts/HeaderAvatar.vue b/packages/stage-layouts/src/components/Layouts/HeaderAvatar.vue index c07f4204d..be64618e9 100644 --- a/packages/stage-layouts/src/components/Layouts/HeaderAvatar.vue +++ b/packages/stage-layouts/src/components/Layouts/HeaderAvatar.vue @@ -8,7 +8,7 @@ import { RouterLink } from 'vue-router' import { toast } from 'vue-sonner' const authStore = useAuthStore() -const { isAuthenticated, user } = storeToRefs(authStore) +const { isAuthenticated, user, credits } = storeToRefs(authStore) const isMobile = useMediaQuery('(max-width: 768px)') @@ -128,6 +128,10 @@ async function handleListSessions() {

{{ userName }}

+
+
+ {{ credits }} Flux +
@@ -139,6 +143,15 @@ async function handleListSessions() { Active Sessions + +
+ Flux + + +import { client } from '@proj-airi/stage-ui/composables/api' +import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' +import { Button } from '@proj-airi/ui' +import { storeToRefs } from 'pinia' +import { computed, onMounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRoute, useRouter } from 'vue-router' + +const { t } = useI18n() +const route = useRoute() +const router = useRouter() +const authStore = useAuthStore() +const { credits } = storeToRefs(authStore) + +const loadingAmount = ref(null) +const message = ref<{ type: 'success' | 'error', text: string } | null>(null) + +// Packages with i18n labels +const packages = computed(() => [ + { amount: 500, label: t('settings.pages.flux.packages.amount_500.label'), price: t('settings.pages.flux.packages.amount_500.price') }, + { amount: 1000, label: t('settings.pages.flux.packages.amount_1000.label'), price: t('settings.pages.flux.packages.amount_1000.price') }, + { amount: 5000, label: t('settings.pages.flux.packages.amount_5000.label'), price: t('settings.pages.flux.packages.amount_5000.price') }, +]) + +onMounted(async () => { + if (route.query.success === 'true') { + message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') } + await authStore.updateCredits() + router.replace({ query: {} }) + } + else if (route.query.canceled === 'true') { + message.value = { type: 'error', text: t('settings.pages.flux.checkout.canceled') } + router.replace({ query: {} }) + } +}) + +async function handleBuy(amount: number) { + loadingAmount.value = amount + message.value = null + try { + const res = await client.api.stripe.checkout.$post({ json: { amount } }) + if (!res.ok) { + const data = await res.json() as { error?: string, message?: string } + message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') } + return + } + const data = await res.json() + if (data.url) { + window.location.href = data.url + } + } + catch { + message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') } + } + finally { + loadingAmount.value = null + } +} + + + + + +meta: + layout: settings + titleKey: settings.pages.flux.title + icon: i-solar:battery-charge-bold-duotone + diff --git a/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue b/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue new file mode 100644 index 000000000..61cf4c709 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/chat/official-provider.vue @@ -0,0 +1,99 @@ + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue index 87254cc70..cbbce2e95 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue @@ -1,28 +1,33 @@ diff --git a/packages/stage-ui/src/composables/api.ts b/packages/stage-ui/src/composables/api.ts index 76611190b..3a4f4ca5a 100644 --- a/packages/stage-ui/src/composables/api.ts +++ b/packages/stage-ui/src/composables/api.ts @@ -2,7 +2,7 @@ import type { AppType } from '../../../../apps/server/src/app' import { hc } from 'hono/client' -import { SERVER_URL } from '../libs/auth' +import { SERVER_URL } from '../libs/server' export const client = hc(SERVER_URL, { fetch: (input: RequestInfo | URL, init?: RequestInit) => { diff --git a/packages/stage-ui/src/libs/auth.ts b/packages/stage-ui/src/libs/auth.ts index a71d72caf..d2dd71329 100644 --- a/packages/stage-ui/src/libs/auth.ts +++ b/packages/stage-ui/src/libs/auth.ts @@ -1,11 +1,10 @@ import { createAuthClient } from 'better-auth/vue' import { useAuthStore } from '../stores/auth' +import { SERVER_URL } from './server' export type OAuthProvider = 'google' | 'github' -export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai' - export const authClient = createAuthClient({ baseURL: SERVER_URL, credentials: 'include', diff --git a/packages/stage-ui/src/libs/server.ts b/packages/stage-ui/src/libs/server.ts new file mode 100644 index 000000000..7b3cf2e10 --- /dev/null +++ b/packages/stage-ui/src/libs/server.ts @@ -0,0 +1 @@ +export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai' diff --git a/packages/stage-ui/src/stores/auth.ts b/packages/stage-ui/src/stores/auth.ts index 53c522f61..57bec45fd 100644 --- a/packages/stage-ui/src/stores/auth.ts +++ b/packages/stage-ui/src/stores/auth.ts @@ -1,9 +1,12 @@ import type { Session, User } from 'better-auth' import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' +import { client } from '../composables/api' import { fetchSession } from '../libs/auth' +import { useConsciousnessStore } from './modules/consciousness' +import { useProvidersStore } from './providers' export const useAuthStore = defineStore('auth', () => { const user = ref() @@ -11,8 +14,9 @@ export const useAuthStore = defineStore('auth', () => { const isAuthenticated = computed(() => !!user.value && !!session.value) const userId = computed(() => user.value?.id ?? 'local') - // For controlling the login drawer on mobile - const isLoginDrawerOpen = ref(false) + const credits = ref(0) + + const isLoginOpen = ref(false) const initialized = ref(false) const initialize = () => { @@ -24,6 +28,41 @@ export const useAuthStore = defineStore('auth', () => { initialized.value = true } + const updateCredits = async () => { + if (!isAuthenticated.value) + return + const res = await client.api.flux.$get() + if (res.ok) { + const data = await res.json() + credits.value = data.flux + } + } + + // Get store references once + const providersStore = useProvidersStore() + const consciousnessStore = useConsciousnessStore() + + watch(isAuthenticated, async (val) => { + if (val) { + updateCredits() + + // Automatically enable official provider when authenticated + const officialProviderId = 'official-provider' + providersStore.forceProviderConfigured(officialProviderId) + consciousnessStore.activeProvider = officialProviderId + await nextTick() + try { + await consciousnessStore.loadModelsForProvider(officialProviderId) + } + catch (err) { + console.error('error loading models for official provider', err) + } + } + else { + credits.value = 0 + } + }, { immediate: true }) + initialize() return { @@ -31,6 +70,8 @@ export const useAuthStore = defineStore('auth', () => { userId, session, isAuthenticated, - isLoginDrawerOpen, + credits, + updateCredits, + isLoginOpen, } }) diff --git a/packages/stage-ui/src/stores/onboarding.ts b/packages/stage-ui/src/stores/onboarding.ts index 951d5dca7..10fe2a51b 100644 --- a/packages/stage-ui/src/stores/onboarding.ts +++ b/packages/stage-ui/src/stores/onboarding.ts @@ -23,21 +23,17 @@ export const useOnboardingStore = defineStore('onboarding', () => { // Check if any essential provider is configured const hasEssentialProviderConfigured = computed(() => { - return essentialProviderIds.some(providerId => providersStore.configuredProviders[providerId]) - }) - - // Fallback for app startup timing: - // If configured state has not been revalidated yet, infer "configured" - // from persisted essential credentials. - const hasEssentialProviderCredentialConfigured = computed(() => { - return credentialBasedEssentialProviderIds.some((providerId) => { - const providerConfig = providersStore.providers[providerId] as Record | undefined - if (!providerConfig) { - return false - } - - return hasNonEmptyText(providerConfig.apiKey) - }) + const essentialProviders = [ + 'openai', + 'anthropic', + 'google-generative-ai', + 'openrouter-ai', + 'ollama', + 'deepseek', + 'openai-compatible', + 'official-provider', + ] + return essentialProviders.some(providerId => providersStore.configuredProviders[providerId]) }) // Check if first-time setup should be shown diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 4956075ec..1b6a75d6d 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -48,7 +48,8 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { listProviders as listDefinedProviders } from '../libs/providers' -import { getProviderValidationIntervalMs } from '../libs/providers/validators/run' +import { SERVER_URL } from '../libs/server' +import { useAuthStore } from '../stores/auth' import { getKokoroWorker } from '../workers/kokoro' import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants' import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription' @@ -249,32 +250,43 @@ export const useProvidersStore = defineStore('providers', () => { // Centralized provider metadata with provider factory functions const providerMetadata: Record = { - 'speech-noop': { - id: 'speech-noop', - category: 'speech', - tasks: ['text-to-speech', 'tts'], - nameKey: 'settings.pages.providers.provider.speech-noop.title', - name: 'None', - descriptionKey: 'settings.pages.providers.provider.speech-noop.description', - description: 'No speech output.', - icon: 'i-solar:volume-cross-bold-duotone', - defaultOptions: () => ({}), - createProvider: async () => ({ - speech: () => ({ - baseURL: 'http://speech-noop.invalid/v1/', - model: 'noop', - }), - }), + 'official-provider': { + id: 'official-provider', + order: -1, + category: 'chat', + tasks: ['text-generation'], + nameKey: 'settings.pages.providers.provider.official.title', + name: 'Official Provider', + descriptionKey: 'settings.pages.providers.provider.official.description', + description: 'Official AI provider by AIRI.', + icon: 'i-solar:star-bold-duotone', + createProvider: async (_config) => { + const authStore = useAuthStore() + if (!authStore.isAuthenticated) { + throw new Error('User is not authenticated') + } + return createOpenAI('', `${SERVER_URL}/v1/`) + }, capabilities: { - listModels: async () => [], - listVoices: async () => [], + listModels: async () => { + return [ + { + id: 'gpt-4o', + name: 'GPT-4o', + provider: 'official-provider', + }, + ] + }, }, validators: { - validateProviderConfig: () => ({ - errors: [], - reason: '', - valid: true, - }), + validateProviderConfig: () => { + const authStore = useAuthStore() + return { + errors: [], + reason: '', + valid: authStore.isAuthenticated, + } + }, }, }, 'app-local-audio-speech': buildOpenAICompatibleProvider({ @@ -1788,10 +1800,18 @@ export const useProvidersStore = defineStore('providers', () => { if (!forceValidation && runtimeState?.validatedCredentialHash === configString && typeof runtimeState.isConfigured === 'boolean') return runtimeState.isConfigured - if (!forceValidation) { - const pending = providerValidationInFlight.get(cacheKey) - if (pending) { - return pending + // Always cache the current config string to prevent re-validating the same config + if (providerRuntimeState.value[providerId]) { + providerRuntimeState.value[providerId].validatedCredentialHash = configString + } + + const validationResult = await metadata.validators.validateProviderConfig(config || {}) + + if (providerRuntimeState.value[providerId]) { + providerRuntimeState.value[providerId].isConfigured = validationResult.valid + // Auto-mark Web Speech API as added if valid and available + if (validationResult.valid && ['browser-web-speech-api', 'player2', 'official-provider'].includes(providerId)) { + markProviderAdded(providerId) } } @@ -1845,6 +1865,14 @@ export const useProvidersStore = defineStore('providers', () => { modelLoadError: null, } } + + // Must run AFTER runtime state is created so forceProviderConfigured can set isConfigured + if (providerId === 'official-provider') { + const authStore = useAuthStore() + if (authStore.isAuthenticated) { + forceProviderConfigured(providerId) + } + } } // Initialize all providers @@ -1891,6 +1919,9 @@ export const useProvidersStore = defineStore('providers', () => { watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true }) startPeriodicRuntimeValidation() + const authStore = useAuthStore() + watch(() => authStore.isAuthenticated, updateConfigurationStatus) + // Available providers (only those that are properly configured) const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 70d827eca..0d3f749fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -101,6 +101,7 @@ catalog: posthog-js: 1.306.1 splitpanes: ^4.0.4 std-env: ^4.0.0 + stripe: ^20.3.0 superjson: ^2.2.6 tsdown: ^0.21.4 tsx: ^4.21.0