# Central Auth and Mana Credit System Design **Document Version:** 1.0 **Date:** 2025-11-25 **Status:** Design Specification ## Table of Contents 1. [Overview](#overview) 2. [Database Schema](#database-schema) 3. [API Architecture](#api-architecture) 4. [Authentication Flows](#authentication-flows) 5. [Credit Transaction Logic](#credit-transaction-logic) 6. [Integration Patterns](#integration-patterns) 7. [Migration Scripts](#migration-scripts) --- ## Overview This document specifies the database schema and API architecture for the central authentication and Mana credit system. The system is designed to: - Support Better Auth compatibility for modern authentication - Manage user accounts, sessions, and multi-device support - Track Mana credit balances and transactions atomically - Enable app-specific user data relations - Provide webhook/event system for real-time updates ### Design Principles 1. **Atomic Transactions**: All credit operations use PostgreSQL transactions 2. **Multi-Tenancy**: Support multiple apps sharing the same auth system 3. **Audit Trail**: Complete transaction history with metadata 4. **Type Safety**: Compatible with Drizzle ORM for TypeScript type generation 5. **Security**: Row-Level Security (RLS) policies for data isolation 6. **Scalability**: Indexed columns for performance --- ## Database Schema ### Schema: `auth` All authentication-related tables live in the `auth` schema. ### 1. Users Table Core user identity table, compatible with Better Auth. ```sql CREATE TABLE auth.users ( -- Primary identification id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Authentication email TEXT UNIQUE NOT NULL, email_verified BOOLEAN DEFAULT false, email_verified_at TIMESTAMPTZ, -- Profile name TEXT, image TEXT, -- Avatar URL -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, deleted_at TIMESTAMPTZ, -- Soft delete support -- Indexes CONSTRAINT users_email_lowercase CHECK (email = LOWER(email)) ); -- Indexes CREATE INDEX idx_users_email ON auth.users(email) WHERE deleted_at IS NULL; CREATE INDEX idx_users_created_at ON auth.users(created_at); CREATE INDEX idx_users_deleted_at ON auth.users(deleted_at) WHERE deleted_at IS NOT NULL; -- Updated timestamp trigger CREATE OR REPLACE FUNCTION auth.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Comments COMMENT ON TABLE auth.users IS 'Core user identity table compatible with Better Auth'; COMMENT ON COLUMN auth.users.deleted_at IS 'Soft delete timestamp. User is deleted if NOT NULL'; ``` ### 2. Accounts Table OAuth and social login provider accounts linked to users. ```sql CREATE TABLE auth.accounts ( -- Primary key id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- User reference user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- Provider information provider TEXT NOT NULL, -- 'email', 'google', 'apple', 'github', etc. provider_account_id TEXT NOT NULL, -- Provider's unique user ID -- OAuth tokens (encrypted at application level) access_token TEXT, refresh_token TEXT, expires_at TIMESTAMPTZ, token_type TEXT, scope TEXT, id_token TEXT, -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, -- Constraints UNIQUE(provider, provider_account_id) ); -- Indexes CREATE INDEX idx_accounts_user_id ON auth.accounts(user_id); CREATE INDEX idx_accounts_provider ON auth.accounts(provider); CREATE UNIQUE INDEX idx_accounts_provider_account ON auth.accounts(provider, provider_account_id); -- Trigger CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON auth.accounts FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Comments COMMENT ON TABLE auth.accounts IS 'OAuth and social login provider accounts'; COMMENT ON COLUMN auth.accounts.provider IS 'Authentication provider: email, google, apple, github'; ``` ### 3. Sessions Table Active user sessions for token management. ```sql CREATE TABLE auth.sessions ( -- Primary key id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- User reference user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- Session tokens session_token TEXT UNIQUE NOT NULL, refresh_token TEXT UNIQUE NOT NULL, -- Token lifecycle expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, last_active_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, -- Device information device_id TEXT, device_name TEXT, device_type TEXT, -- 'web', 'ios', 'android', 'desktop' platform TEXT, -- IP and location ip_address INET, user_agent TEXT, -- App context app_id TEXT NOT NULL, -- Which app this session belongs to -- Status revoked BOOLEAN DEFAULT false, revoked_at TIMESTAMPTZ ); -- Indexes CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); CREATE INDEX idx_sessions_session_token ON auth.sessions(session_token) WHERE NOT revoked; CREATE INDEX idx_sessions_refresh_token ON auth.sessions(refresh_token) WHERE NOT revoked; CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); CREATE INDEX idx_sessions_app_id ON auth.sessions(app_id); CREATE INDEX idx_sessions_device_id ON auth.sessions(device_id); -- Auto-update last_active_at CREATE OR REPLACE FUNCTION auth.update_session_last_active() RETURNS TRIGGER AS $$ BEGIN NEW.last_active_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_sessions_last_active BEFORE UPDATE ON auth.sessions FOR EACH ROW WHEN (OLD.session_token IS DISTINCT FROM NEW.session_token OR OLD.refresh_token IS DISTINCT FROM NEW.refresh_token) EXECUTE FUNCTION auth.update_session_last_active(); -- Comments COMMENT ON TABLE auth.sessions IS 'Active user sessions for multi-device support'; COMMENT ON COLUMN auth.sessions.app_id IS 'Application identifier (e.g., memoro, manadeck, picture)'; ``` ### 4. Password Reset Tokens Temporary tokens for password reset flows. ```sql CREATE TABLE auth.password_reset_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, used_at TIMESTAMPTZ ); -- Indexes CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id); CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token) WHERE used_at IS NULL; CREATE INDEX idx_password_reset_tokens_expires_at ON auth.password_reset_tokens(expires_at); COMMENT ON TABLE auth.password_reset_tokens IS 'Temporary tokens for password reset'; ``` ### 5. Email Verification Tokens Tokens for email verification. ```sql CREATE TABLE auth.email_verification_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, verified_at TIMESTAMPTZ ); -- Indexes CREATE INDEX idx_email_verification_tokens_user_id ON auth.email_verification_tokens(user_id); CREATE INDEX idx_email_verification_tokens_token ON auth.email_verification_tokens(token) WHERE verified_at IS NULL; CREATE INDEX idx_email_verification_tokens_expires_at ON auth.email_verification_tokens(expires_at); COMMENT ON TABLE auth.email_verification_tokens IS 'Tokens for email verification'; ``` --- ### Schema: `credits` All credit-related tables live in the `credits` schema. ### 6. Credit Balances Table Current credit balance per user (single source of truth). ```sql CREATE TABLE credits.balances ( -- Primary key user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, -- Balance tracking balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0), max_credit_limit INTEGER NOT NULL DEFAULT 1000, -- Free tier tracking free_credits_remaining INTEGER NOT NULL DEFAULT 150, -- Initial free credits daily_free_credits INTEGER NOT NULL DEFAULT 5, -- Daily bonus last_daily_credit_at DATE, -- Last time daily credits were claimed -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, -- Lifetime statistics total_earned INTEGER DEFAULT 0, total_spent INTEGER DEFAULT 0, total_purchased INTEGER DEFAULT 0 ); -- Indexes CREATE INDEX idx_balances_balance ON credits.balances(balance); CREATE INDEX idx_balances_updated_at ON credits.balances(updated_at); -- Trigger for updated_at CREATE OR REPLACE FUNCTION credits.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_balances_updated_at BEFORE UPDATE ON credits.balances FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); -- Auto-create balance for new users CREATE OR REPLACE FUNCTION credits.create_balance_for_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO credits.balances (user_id) VALUES (NEW.id) ON CONFLICT (user_id) DO NOTHING; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER create_balance_on_user_creation AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION credits.create_balance_for_new_user(); -- Comments COMMENT ON TABLE credits.balances IS 'Current credit balance per user (single source of truth)'; COMMENT ON COLUMN credits.balances.balance IS 'Current available credits. MUST be >= 0'; COMMENT ON COLUMN credits.balances.max_credit_limit IS 'Maximum credits user can hold (prevents abuse)'; ``` ### 7. Transactions Table Complete audit trail of all credit operations. ```sql CREATE TABLE credits.transactions ( -- Primary key id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- User reference user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, -- Transaction details type TEXT NOT NULL, -- 'purchase', 'usage', 'refund', 'admin_adjustment', 'daily_bonus', 'signup_bonus' operation TEXT NOT NULL, -- App-specific operation (e.g., 'DECK_CREATION', 'STORY_GENERATION') amount INTEGER NOT NULL, -- Positive for credits added, negative for credits spent -- Balance tracking (for audit) balance_before INTEGER NOT NULL, balance_after INTEGER NOT NULL, -- Context app_id TEXT NOT NULL, -- Which app triggered this transaction description TEXT NOT NULL, metadata JSONB, -- Flexible storage for operation-specific data -- References reference_id TEXT, -- External reference (e.g., Stripe payment ID, RevenueCat transaction ID) related_transaction_id UUID REFERENCES credits.transactions(id), -- For refunds/adjustments -- Timestamps created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, -- Constraints CHECK ( (type = 'usage' AND amount < 0) OR (type IN ('purchase', 'refund', 'admin_adjustment', 'daily_bonus', 'signup_bonus') AND amount > 0) OR (type = 'admin_adjustment') ) ); -- Indexes CREATE INDEX idx_transactions_user_id ON credits.transactions(user_id); CREATE INDEX idx_transactions_type ON credits.transactions(type); CREATE INDEX idx_transactions_app_id ON credits.transactions(app_id); CREATE INDEX idx_transactions_operation ON credits.transactions(operation); CREATE INDEX idx_transactions_created_at ON credits.transactions(created_at DESC); CREATE INDEX idx_transactions_reference_id ON credits.transactions(reference_id) WHERE reference_id IS NOT NULL; CREATE INDEX idx_transactions_metadata ON credits.transactions USING GIN(metadata); -- Comments COMMENT ON TABLE credits.transactions IS 'Complete audit trail of all credit operations'; COMMENT ON COLUMN credits.transactions.type IS 'Transaction type: purchase, usage, refund, admin_adjustment, daily_bonus, signup_bonus'; COMMENT ON COLUMN credits.transactions.amount IS 'Positive for credits added, negative for credits spent'; COMMENT ON COLUMN credits.transactions.metadata IS 'Flexible JSONB storage for operation-specific data'; ``` ### 8. Credit Packages Table Available credit packages for purchase. ```sql CREATE TABLE credits.packages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Package details name TEXT NOT NULL, credits INTEGER NOT NULL CHECK (credits > 0), price_cents INTEGER NOT NULL CHECK (price_cents >= 0), currency TEXT NOT NULL DEFAULT 'EUR', -- Display description TEXT, badge TEXT, -- e.g., 'BEST VALUE', 'POPULAR' sort_order INTEGER DEFAULT 0, -- Status active BOOLEAN DEFAULT true, -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); -- Indexes CREATE INDEX idx_packages_active ON credits.packages(active, sort_order); -- Trigger CREATE TRIGGER update_packages_updated_at BEFORE UPDATE ON credits.packages FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); -- Seed default packages INSERT INTO credits.packages (name, credits, price_cents, badge, sort_order) VALUES ('Starter Pack', 100, 99, NULL, 1), ('Power Pack', 500, 499, 'POPULAR', 2), ('Pro Pack', 1000, 899, 'BEST VALUE', 3), ('Ultimate Pack', 5000, 3999, NULL, 4); COMMENT ON TABLE credits.packages IS 'Available credit packages for purchase'; ``` ### 9. Operation Costs Table Credit costs per operation per app. ```sql CREATE TABLE credits.operation_costs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Operation identification app_id TEXT NOT NULL, operation TEXT NOT NULL, cost INTEGER NOT NULL CHECK (cost >= 0), -- Display display_name TEXT NOT NULL, description TEXT, -- Status active BOOLEAN DEFAULT true, -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, -- Constraints UNIQUE(app_id, operation) ); -- Indexes CREATE INDEX idx_operation_costs_app_id ON credits.operation_costs(app_id); CREATE INDEX idx_operation_costs_active ON credits.operation_costs(active); CREATE UNIQUE INDEX idx_operation_costs_app_operation ON credits.operation_costs(app_id, operation) WHERE active; -- Trigger CREATE TRIGGER update_operation_costs_updated_at BEFORE UPDATE ON credits.operation_costs FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); -- Seed operation costs for existing apps INSERT INTO credits.operation_costs (app_id, operation, cost, display_name, description) VALUES -- Manadeck ('manadeck', 'DECK_CREATION', 10, 'Create Deck', 'Create a new flashcard deck'), ('manadeck', 'CARD_CREATION', 2, 'Add Card', 'Add a single card to a deck'), ('manadeck', 'AI_CARD_GENERATION', 5, 'AI Card Generation', 'Generate a card using AI'), ('manadeck', 'DECK_EXPORT', 3, 'Export Deck', 'Export deck to various formats'), -- Maerchenzauber ('maerchenzauber', 'STORY_GENERATION', 50, 'Generate Story', 'Generate a new AI story'), ('maerchenzauber', 'CHARACTER_CREATION', 20, 'Create Character', 'Create a custom character'), ('maerchenzauber', 'IMAGE_GENERATION', 30, 'Generate Image', 'Generate story illustration'), -- Memoro ('memoro', 'TRANSCRIPTION_PER_HOUR', 120, 'Audio Transcription', 'Per hour of audio transcribed'), ('memoro', 'HEADLINE_GENERATION', 10, 'Generate Headline', 'AI-generated memo headline'), ('memoro', 'MEMORY_CREATION', 10, 'Create Memory', 'Generate memory from memo'), ('memoro', 'BLUEPRINT_PROCESSING', 5, 'Process Blueprint', 'Apply AI blueprint to memo'), -- Picture ('picture', 'IMAGE_GENERATION', 25, 'Generate Image', 'AI image generation'), ('picture', 'IMAGE_UPSCALE', 15, 'Upscale Image', 'Upscale image quality'), ('picture', 'STYLE_TRANSFER', 20, 'Style Transfer', 'Apply style to image'); COMMENT ON TABLE credits.operation_costs IS 'Credit costs per operation per app'; ``` --- ### Schema: `app_data` App-specific user data relations. ### 10. App User Settings Per-app user preferences and settings. ```sql CREATE TABLE app_data.user_settings ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, app_id TEXT NOT NULL, -- Settings stored as JSONB for flexibility settings JSONB NOT NULL DEFAULT '{}', -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, PRIMARY KEY (user_id, app_id) ); -- Indexes CREATE INDEX idx_user_settings_app_id ON app_data.user_settings(app_id); CREATE INDEX idx_user_settings_settings ON app_data.user_settings USING GIN(settings); -- Trigger CREATE OR REPLACE FUNCTION app_data.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON app_data.user_settings FOR EACH ROW EXECUTE FUNCTION app_data.update_updated_at_column(); COMMENT ON TABLE app_data.user_settings IS 'Per-app user preferences and settings'; ``` --- ### Schema: `webhooks` Event system for real-time credit updates. ### 11. Webhook Endpoints Registered webhooks for apps. ```sql CREATE TABLE webhooks.endpoints ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- App identification app_id TEXT NOT NULL, -- Webhook details url TEXT NOT NULL, secret TEXT NOT NULL, -- For HMAC signature verification -- Event filters events TEXT[] NOT NULL DEFAULT '{credit.updated, credit.low_balance}', -- Status active BOOLEAN DEFAULT true, -- Retry configuration max_retries INTEGER DEFAULT 3, retry_delay_seconds INTEGER DEFAULT 60, -- Statistics last_success_at TIMESTAMPTZ, last_failure_at TIMESTAMPTZ, failure_count INTEGER DEFAULT 0, -- Metadata created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); -- Indexes CREATE INDEX idx_webhook_endpoints_app_id ON webhooks.endpoints(app_id); CREATE INDEX idx_webhook_endpoints_active ON webhooks.endpoints(active); COMMENT ON TABLE webhooks.endpoints IS 'Registered webhooks for apps'; ``` ### 12. Webhook Delivery Log Audit trail of webhook deliveries. ```sql CREATE TABLE webhooks.delivery_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- References endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE, -- Event details event_type TEXT NOT NULL, payload JSONB NOT NULL, -- Delivery status status TEXT NOT NULL, -- 'pending', 'success', 'failed', 'retrying' attempt_count INTEGER DEFAULT 0, -- Response response_status_code INTEGER, response_body TEXT, error_message TEXT, -- Timestamps created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, delivered_at TIMESTAMPTZ, next_retry_at TIMESTAMPTZ ); -- Indexes CREATE INDEX idx_delivery_log_endpoint_id ON webhooks.delivery_log(endpoint_id); CREATE INDEX idx_delivery_log_status ON webhooks.delivery_log(status); CREATE INDEX idx_delivery_log_created_at ON webhooks.delivery_log(created_at DESC); CREATE INDEX idx_delivery_log_next_retry ON webhooks.delivery_log(next_retry_at) WHERE status = 'retrying'; COMMENT ON TABLE webhooks.delivery_log IS 'Audit trail of webhook deliveries'; ``` --- ## API Architecture ### Base URL ``` https://mana-core-middleware-111768794939.europe-west3.run.app ``` ### API Design Principles 1. **RESTful**: Standard HTTP methods and status codes 2. **Versioned**: `/v1/` prefix for API versioning 3. **JWT Authentication**: Bearer token in `Authorization` header 4. **JSON**: All requests and responses use `application/json` 5. **Rate Limited**: 100 requests/minute per user 6. **CORS Enabled**: For web client support --- ### Authentication Endpoints #### POST /auth/register Register a new user. **Request:** ```json { "email": "user@example.com", "password": "SecurePass123!", "name": "John Doe", "deviceInfo": { "deviceId": "abc123", "deviceName": "iPhone 14", "deviceType": "ios", "platform": "mobile" } } ``` **Response (201 Created):** ```json { "user": { "id": "uuid", "email": "user@example.com", "name": "John Doe", "emailVerified": false, "createdAt": "2025-11-25T10:00:00Z" }, "tokens": { "manaToken": "jwt...", // Internal token "appToken": "jwt...", // Supabase-compatible JWT "refreshToken": "rt_..." }, "needsVerification": true } ``` **Errors:** - `400`: Invalid input (weak password, invalid email) - `409`: Email already registered --- #### POST /auth/login Login with email and password. **Request:** ```json { "email": "user@example.com", "password": "SecurePass123!", "deviceInfo": { "deviceId": "abc123", "deviceName": "iPhone 14", "deviceType": "ios", "platform": "mobile" } } ``` **Response (200 OK):** ```json { "user": { "id": "uuid", "email": "user@example.com", "name": "John Doe", "emailVerified": true }, "tokens": { "manaToken": "jwt...", "appToken": "jwt...", "refreshToken": "rt_..." }, "credits": { "balance": 150, "maxCreditLimit": 1000 } } ``` **Errors:** - `401`: Invalid credentials - `403`: Email not verified - `429`: Too many login attempts --- #### POST /auth/refresh Refresh access token using refresh token. **Request:** ```json { "refreshToken": "rt_...", "deviceInfo": { "deviceId": "abc123" } } ``` **Response (200 OK):** ```json { "tokens": { "manaToken": "jwt...", "appToken": "jwt...", "refreshToken": "rt_..." } } ``` **Errors:** - `401`: Invalid or expired refresh token - `403`: Device ID mismatch --- #### POST /auth/logout Revoke current session. **Request:** ```json { "refreshToken": "rt_..." } ``` **Response (204 No Content)** --- #### POST /auth/forgot-password Request password reset. **Request:** ```json { "email": "user@example.com" } ``` **Response (200 OK):** ```json { "message": "Password reset email sent" } ``` --- #### POST /auth/reset-password Reset password with token. **Request:** ```json { "token": "reset_token_...", "newPassword": "NewSecurePass123!" } ``` **Response (200 OK):** ```json { "message": "Password reset successful" } ``` **Errors:** - `400`: Invalid or expired token - `400`: Weak password --- #### POST /auth/verify-email Verify email with token. **Request:** ```json { "token": "verify_token_..." } ``` **Response (200 OK):** ```json { "message": "Email verified successfully" } ``` --- #### POST /auth/google-signin Sign in with Google OAuth. **Request:** ```json { "token": "google_id_token...", "deviceInfo": { "deviceId": "abc123", "deviceName": "iPhone 14", "deviceType": "ios" } } ``` **Response (200 OK):** ```json { "user": { "id": "uuid", "email": "user@gmail.com", "name": "John Doe", "image": "https://..." }, "tokens": { "manaToken": "jwt...", "appToken": "jwt...", "refreshToken": "rt_..." }, "isNewUser": false } ``` --- #### POST /auth/apple-signin Sign in with Apple. **Request:** ```json { "token": "apple_id_token...", "deviceInfo": { "deviceId": "abc123" } } ``` **Response:** Same as Google sign-in --- ### User Management Endpoints #### GET /users/me Get current user profile. **Headers:** ``` Authorization: Bearer ``` **Response (200 OK):** ```json { "id": "uuid", "email": "user@example.com", "name": "John Doe", "image": null, "emailVerified": true, "createdAt": "2025-11-25T10:00:00Z" } ``` --- #### PATCH /users/me Update user profile. **Request:** ```json { "name": "Jane Doe", "image": "https://..." } ``` **Response (200 OK):** ```json { "id": "uuid", "email": "user@example.com", "name": "Jane Doe", "image": "https://...", "updatedAt": "2025-11-25T10:30:00Z" } ``` --- #### DELETE /users/me Delete user account (soft delete). **Response (204 No Content)** --- #### GET /users/me/sessions List all active sessions. **Response (200 OK):** ```json { "sessions": [ { "id": "uuid", "deviceName": "iPhone 14", "deviceType": "ios", "lastActiveAt": "2025-11-25T10:00:00Z", "ipAddress": "192.168.1.1", "current": true }, { "id": "uuid", "deviceName": "Chrome on MacBook", "deviceType": "web", "lastActiveAt": "2025-11-24T15:00:00Z", "ipAddress": "192.168.1.2", "current": false } ] } ``` --- #### DELETE /users/me/sessions/:sessionId Revoke a specific session. **Response (204 No Content)** --- ### Credit Endpoints #### GET /credits/balance Get user's current credit balance. **Headers:** ``` Authorization: Bearer ``` **Response (200 OK):** ```json { "userId": "uuid", "balance": 150, "maxCreditLimit": 1000, "freeCreditsRemaining": 50, "dailyFreeCredits": 5, "lastDailyCreditAt": "2025-11-25", "totalEarned": 200, "totalSpent": 50, "totalPurchased": 0 } ``` --- #### POST /credits/validate Validate if user has enough credits for an operation. **Request:** ```json { "appId": "manadeck", "operation": "DECK_CREATION", "amount": 10 } ``` **Response (200 OK):** ```json { "hasCredits": true, "currentBalance": 150, "requiredAmount": 10, "balanceAfter": 140, "operationCost": 10 } ``` **Response (400 Bad Request - Insufficient Credits):** ```json { "hasCredits": false, "currentBalance": 5, "requiredAmount": 10, "shortfall": 5, "error": "insufficient_credits", "message": "You need 5 more credits to perform this operation" } ``` --- #### POST /credits/deduct Deduct credits for an operation. **Request:** ```json { "appId": "manadeck", "operation": "DECK_CREATION", "amount": 10, "description": "Created deck: Spanish Vocabulary", "metadata": { "deckId": "uuid", "deckName": "Spanish Vocabulary" } } ``` **Response (200 OK):** ```json { "success": true, "transactionId": "uuid", "balanceBefore": 150, "balanceAfter": 140, "amountDeducted": 10 } ``` **Errors:** - `400`: Insufficient credits - `404`: Operation cost not found --- #### POST /credits/claim-daily Claim daily free credits. **Response (200 OK):** ```json { "success": true, "creditsAdded": 5, "newBalance": 155, "nextClaimAt": "2025-11-26T00:00:00Z" } ``` **Response (400 Bad Request - Already Claimed):** ```json { "success": false, "message": "Daily credits already claimed today", "nextClaimAt": "2025-11-26T00:00:00Z" } ``` --- #### GET /credits/transactions Get transaction history. **Query Parameters:** - `limit` (default: 50, max: 100) - `offset` (default: 0) - `type` (optional filter: 'purchase', 'usage', 'refund') - `appId` (optional filter) **Response (200 OK):** ```json { "transactions": [ { "id": "uuid", "type": "usage", "operation": "DECK_CREATION", "amount": -10, "balanceBefore": 150, "balanceAfter": 140, "appId": "manadeck", "description": "Created deck: Spanish Vocabulary", "metadata": { "deckId": "uuid" }, "createdAt": "2025-11-25T10:00:00Z" }, { "id": "uuid", "type": "signup_bonus", "operation": "SIGNUP_BONUS", "amount": 150, "balanceBefore": 0, "balanceAfter": 150, "appId": "system", "description": "Welcome bonus", "createdAt": "2025-11-25T09:00:00Z" } ], "pagination": { "total": 2, "limit": 50, "offset": 0 } } ``` --- #### GET /credits/packages Get available credit packages for purchase. **Response (200 OK):** ```json { "packages": [ { "id": "uuid", "name": "Starter Pack", "credits": 100, "priceCents": 99, "currency": "EUR", "badge": null }, { "id": "uuid", "name": "Power Pack", "credits": 500, "priceCents": 499, "currency": "EUR", "badge": "POPULAR" }, { "id": "uuid", "name": "Pro Pack", "credits": 1000, "priceCents": 899, "currency": "EUR", "badge": "BEST VALUE" } ] } ``` --- #### POST /credits/purchase Initiate credit purchase (webhook from payment provider). **Request:** ```json { "packageId": "uuid", "paymentProvider": "stripe", "paymentIntentId": "pi_...", "amount": 499 } ``` **Response (200 OK):** ```json { "success": true, "transactionId": "uuid", "creditsAdded": 500, "newBalance": 650 } ``` --- #### GET /credits/operation-costs Get credit costs for all operations in an app. **Query Parameters:** - `appId` (required) **Response (200 OK):** ```json { "appId": "manadeck", "operations": [ { "operation": "DECK_CREATION", "cost": 10, "displayName": "Create Deck", "description": "Create a new flashcard deck" }, { "operation": "CARD_CREATION", "cost": 2, "displayName": "Add Card", "description": "Add a single card to a deck" } ] } ``` --- ### Admin Endpoints All admin endpoints require `admin` role in JWT. #### POST /admin/credits/adjust Manually adjust user credits (admin only). **Request:** ```json { "userId": "uuid", "amount": 100, "reason": "Compensation for service issue" } ``` **Response (200 OK):** ```json { "success": true, "transactionId": "uuid", "newBalance": 250 } ``` --- #### GET /admin/users List all users with pagination. **Query Parameters:** - `limit` (default: 50) - `offset` (default: 0) - `search` (optional email search) **Response (200 OK):** ```json { "users": [...], "pagination": { "total": 1000, "limit": 50, "offset": 0 } } ``` --- #### PATCH /admin/operation-costs/:id Update operation cost. **Request:** ```json { "cost": 15 } ``` **Response (200 OK):** ```json { "id": "uuid", "operation": "DECK_CREATION", "cost": 15, "updatedAt": "2025-11-25T11:00:00Z" } ``` --- ## Authentication Flows ### 1. Email/Password Registration Flow ``` ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ API │ │ Database │ └────┬────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ POST /auth/register │ │ │ {email, password, name} │ │ ├─────────────────────────>│ │ │ │ │ │ │ 1. Hash password (bcrypt) │ │ │ │ │ │ BEGIN TRANSACTION │ │ ├──────────────────────────>│ │ │ │ │ │ 2. INSERT INTO auth.users │ │ ├──────────────────────────>│ │ │ │ │ │ 3. Trigger creates balance│ │ │<──────────────────────────┤ │ │ credits.balances (150) │ │ │ │ │ │ 4. INSERT INTO accounts │ │ ├──────────────────────────>│ │ │ (provider='email') │ │ │ │ │ │ 5. Generate verification │ │ │ token │ │ ├──────────────────────────>│ │ │ │ │ │ COMMIT │ │ │<──────────────────────────┤ │ │ │ │ │ 6. Send verification email│ │ │ (async) │ │ │ │ │ │ 7. Generate JWT tokens │ │ │ - manaToken │ │ │ - appToken │ │ │ - refreshToken │ │ │ │ │ 201 Created │ │ │ {user, tokens, │ │ │ needsVerification: true}│ │ │<─────────────────────────┤ │ │ │ │ ``` ### 2. OAuth Sign-In Flow (Google/Apple) ``` ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ OAuth │ │ API │ │ Database │ │ OAuth │ │ │ │ Provider │ │ │ │ │ │ Provider │ └────┬────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ (G/A) │ │ │ │ │ └────┬─────┘ │ 1. Initiate │ │ │ │ │ OAuth │ │ │ │ ├─────────────>│ │ │ │ │ │ │ │ │ │ 2. User auth │ │ │ │ │ & consent │ │ │ │ │<─────────────┤ │ │ │ │ │ │ │ │ │ 3. ID Token │ │ │ │ │<─────────────┤ │ │ │ │ │ │ │ │ │ POST /auth/google-signin │ │ │ │ {token, deviceInfo} │ │ │ ├─────────────────────────────>│ │ │ │ │ │ │ │ │ 4. Verify token with provider │ │ ├───────────────────────────────>│ │ │ │ │ │ 5. Token valid + user info │ │ │<───────────────────────────────┤ │ │ │ │ │ BEGIN TRANSACTION │ │ ├──────────────────────────────>│ │ │ │ │ │ 6. Check existing account │ │ │ by provider_account_id │ │ ├──────────────────────────────>│ │ │ │ │ │ IF NOT EXISTS: │ │ │ 7a. Create user │ │ │ 7b. Create account │ │ │ 7c. Trigger creates balance │ │ ├──────────────────────────────>│ │ │ │ │ │ 8. Create session │ │ ├──────────────────────────────>│ │ │ │ │ │ COMMIT │ │ │<──────────────────────────────┤ │ │ │ │ │ 9. Generate JWT tokens │ │ │ │ │ 200 OK │ │ │ {user, tokens, isNewUser} │ │ │<─────────────────────────────┤ │ │ │ │ ``` ### 3. Token Refresh Flow ``` ┌─────────┐ ┌──────────┐ ┌──────────┐ │ Client │ │ API │ │ Database │ └────┬────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ API Request with expired │ │ │ manaToken → 401 │ │ ├─────────────────────────>│ │ │<─────────────────────────┤ │ │ │ │ │ POST /auth/refresh │ │ │ {refreshToken, deviceInfo│ │ ├─────────────────────────>│ │ │ │ │ │ │ 1. Query session by │ │ │ refresh_token │ │ ├──────────────────────────>│ │ │ │ │ │ 2. Validate session │ │ │ - Not expired │ │ │ - Not revoked │ │ │ - Device ID matches │ │ │<──────────────────────────┤ │ │ │ │ │ BEGIN TRANSACTION │ │ │ │ │ │ 3. Generate new tokens │ │ │ │ │ │ 4. Update session │ │ │ - new session_token │ │ │ - new refresh_token │ │ │ - extends expires_at │ │ ├──────────────────────────>│ │ │ │ │ │ COMMIT │ │ │<──────────────────────────┤ │ │ │ │ 200 OK │ │ │ {tokens} │ │ │<─────────────────────────┤ │ │ │ │ │ Retry original API call │ │ │ with new manaToken │ │ ├─────────────────────────>│ │ │ │ │ ``` ### JWT Token Structure #### manaToken (Internal Use) ```json { "sub": "user_uuid", "email": "user@example.com", "role": "user", "app_id": "manadeck", "session_id": "session_uuid", "exp": 1732540800, "iat": 1732537200, "iss": "mana-core", "aud": "mana-ecosystem" } ``` #### appToken (Supabase-Compatible) ```json { "sub": "user_uuid", "email": "user@example.com", "role": "authenticated", "app_id": "manadeck", "aud": "authenticated", "exp": 1732540800, "iat": 1732537200, "iss": "mana-core", "user_metadata": { "email": "user@example.com" }, "app_settings": { "b2b": { "disableRevenueCat": false } } } ``` --- ## Credit Transaction Logic ### Transaction Workflow All credit operations follow this pattern: ```typescript async function performCreditOperation( userId: string, appId: string, operation: string, description: string, metadata?: Record ): Promise { // Use database transaction for atomicity return await db.transaction(async (tx) => { // 1. Get operation cost const operationCost = await tx.query.operationCosts.findFirst({ where: and( eq(operationCosts.appId, appId), eq(operationCosts.operation, operation), eq(operationCosts.active, true) ) }); if (!operationCost) { throw new NotFoundError(`Operation ${operation} not found for app ${appId}`); } // 2. Lock user's balance row (SELECT FOR UPDATE) const balance = await tx .select() .from(balances) .where(eq(balances.userId, userId)) .for('update'); if (!balance || balance.length === 0) { throw new NotFoundError('User balance not found'); } const currentBalance = balance[0].balance; const requiredAmount = operationCost.cost; // 3. Check sufficient credits if (currentBalance < requiredAmount) { throw new InsufficientCreditsError({ currentBalance, requiredAmount, shortfall: requiredAmount - currentBalance }); } // 4. Calculate new balance const newBalance = currentBalance - requiredAmount; // 5. Update balance await tx .update(balances) .set({ balance: newBalance, totalSpent: sql`total_spent + ${requiredAmount}`, updatedAt: new Date() }) .where(eq(balances.userId, userId)); // 6. Create transaction record const [transaction] = await tx .insert(transactions) .values({ userId, type: 'usage', operation, amount: -requiredAmount, balanceBefore: currentBalance, balanceAfter: newBalance, appId, description, metadata: metadata || {}, createdAt: new Date() }) .returning(); // 7. Trigger webhook (async, outside transaction) process.nextTick(() => { triggerWebhook('credit.updated', { userId, balanceBefore: currentBalance, balanceAfter: newBalance, transactionId: transaction.id, operation, appId }); }); return { success: true, transactionId: transaction.id, balanceBefore: currentBalance, balanceAfter: newBalance, amountDeducted: requiredAmount }; }); } ``` ### Validation Workflow (Pre-Flight Check) ```typescript async function validateCredits( userId: string, appId: string, operation: string ): Promise { // No transaction needed - read-only // 1. Get operation cost const operationCost = await db.query.operationCosts.findFirst({ where: and( eq(operationCosts.appId, appId), eq(operationCosts.operation, operation), eq(operationCosts.active, true) ) }); if (!operationCost) { throw new NotFoundError(`Operation ${operation} not found`); } // 2. Get current balance const balance = await db.query.balances.findFirst({ where: eq(balances.userId, userId) }); if (!balance) { throw new NotFoundError('User balance not found'); } const hasCredits = balance.balance >= operationCost.cost; const shortfall = hasCredits ? 0 : operationCost.cost - balance.balance; return { hasCredits, currentBalance: balance.balance, requiredAmount: operationCost.cost, balanceAfter: hasCredits ? balance.balance - operationCost.cost : null, shortfall, operationCost: operationCost.cost }; } ``` ### Purchase Workflow ```typescript async function purchaseCredits( userId: string, packageId: string, paymentProvider: string, paymentReferenceId: string ): Promise { return await db.transaction(async (tx) => { // 1. Get package details const pkg = await tx.query.packages.findFirst({ where: and( eq(packages.id, packageId), eq(packages.active, true) ) }); if (!pkg) { throw new NotFoundError('Package not found'); } // 2. Lock balance const balance = await tx .select() .from(balances) .where(eq(balances.userId, userId)) .for('update'); const currentBalance = balance[0].balance; const newBalance = currentBalance + pkg.credits; // 3. Check max credit limit if (newBalance > balance[0].maxCreditLimit) { throw new Error(`Exceeds maximum credit limit of ${balance[0].maxCreditLimit}`); } // 4. Update balance await tx .update(balances) .set({ balance: newBalance, totalEarned: sql`total_earned + ${pkg.credits}`, totalPurchased: sql`total_purchased + ${pkg.credits}`, updatedAt: new Date() }) .where(eq(balances.userId, userId)); // 5. Create transaction const [transaction] = await tx .insert(transactions) .values({ userId, type: 'purchase', operation: 'CREDIT_PURCHASE', amount: pkg.credits, balanceBefore: currentBalance, balanceAfter: newBalance, appId: 'system', description: `Purchased ${pkg.name}`, metadata: { packageId: pkg.id, packageName: pkg.name, priceCents: pkg.priceCents, currency: pkg.currency }, referenceId: paymentReferenceId, createdAt: new Date() }) .returning(); // 6. Trigger webhook process.nextTick(() => { triggerWebhook('credit.purchased', { userId, creditsAdded: pkg.credits, newBalance, transactionId: transaction.id, packageName: pkg.name }); }); return { success: true, transactionId: transaction.id, creditsAdded: pkg.credits, newBalance }; }); } ``` ### Daily Credit Claim ```typescript async function claimDailyCredits( userId: string ): Promise { return await db.transaction(async (tx) => { // 1. Lock balance const balance = await tx .select() .from(balances) .where(eq(balances.userId, userId)) .for('update'); const today = new Date().toISOString().split('T')[0]; const lastClaimDate = balance[0].lastDailyCreditAt?.toISOString().split('T')[0]; // 2. Check if already claimed today if (lastClaimDate === today) { throw new Error('Daily credits already claimed today'); } const dailyAmount = balance[0].dailyFreeCredits; const currentBalance = balance[0].balance; const newBalance = currentBalance + dailyAmount; // 3. Update balance await tx .update(balances) .set({ balance: newBalance, totalEarned: sql`total_earned + ${dailyAmount}`, lastDailyCreditAt: new Date(), updatedAt: new Date() }) .where(eq(balances.userId, userId)); // 4. Create transaction const [transaction] = await tx .insert(transactions) .values({ userId, type: 'daily_bonus', operation: 'DAILY_CLAIM', amount: dailyAmount, balanceBefore: currentBalance, balanceAfter: newBalance, appId: 'system', description: 'Daily free credits', createdAt: new Date() }) .returning(); return { success: true, creditsAdded: dailyAmount, newBalance, nextClaimAt: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString() }; }); } ``` --- ## Integration Patterns ### Mobile App Integration (React Native + Expo) #### 1. Setup Auth Service ```typescript // features/auth/services/authService.ts import { createAuthService } from '@manacore/shared-auth'; export const authService = createAuthService({ baseUrl: process.env.EXPO_PUBLIC_MIDDLEWARE_API_URL!, storageKeys: { APP_TOKEN: '@auth/appToken', REFRESH_TOKEN: '@auth/refreshToken', USER_EMAIL: '@auth/userEmail' } }); ``` #### 2. Setup Credit Service ```typescript // features/credits/creditService.ts export class CreditService { private readonly baseUrl: string; constructor() { this.baseUrl = process.env.EXPO_PUBLIC_MIDDLEWARE_API_URL!; } async getBalance(): Promise { const token = await authService.getAppToken(); const response = await fetch(`${this.baseUrl}/credits/balance`, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Failed to fetch credits'); } return response.json(); } async validateOperation( appId: string, operation: string ): Promise { const token = await authService.getAppToken(); const response = await fetch(`${this.baseUrl}/credits/validate`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ appId, operation }) }); const data = await response.json(); if (!response.ok) { if (data.error === 'insufficient_credits') { throw new InsufficientCreditsError(data); } throw new Error(data.message); } return data; } async deductCredits( appId: string, operation: string, description: string, metadata?: Record ): Promise { const token = await authService.getAppToken(); const response = await fetch(`${this.baseUrl}/credits/deduct`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ appId, operation, description, metadata }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.message); } return data; } } export const creditService = new CreditService(); ``` #### 3. Usage in Component ```typescript // app/(protected)/decks/create.tsx import { useState } from 'react'; import { creditService } from '~/features/credits/creditService'; import { InsufficientCreditsModal } from '~/components/InsufficientCreditsModal'; export default function CreateDeckScreen() { const [loading, setLoading] = useState(false); const [showInsufficientCredits, setShowInsufficientCredits] = useState(false); const [creditError, setCreditError] = useState(null); const handleCreateDeck = async (deckData: DeckInput) => { setLoading(true); try { // 1. Validate credits BEFORE operation await creditService.validateOperation('manadeck', 'DECK_CREATION'); // 2. Perform the actual operation const deck = await deckApi.createDeck(deckData); // 3. Deduct credits AFTER success await creditService.deductCredits( 'manadeck', 'DECK_CREATION', `Created deck: ${deckData.name}`, { deckId: deck.id } ); // 4. Success! navigation.navigate('DeckDetail', { deckId: deck.id }); } catch (error) { if (error instanceof InsufficientCreditsError) { setCreditError(error); setShowInsufficientCredits(true); } else { Alert.alert('Error', error.message); } } finally { setLoading(false); } }; return ( {/* Your form UI */} setShowInsufficientCredits(false)} onPurchase={() => navigation.navigate('CreditStore')} /> ); } ``` --- ### Web App Integration (SvelteKit) #### 1. Setup Server-Side Auth ```typescript // src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; import jwt from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET!; export const handle: Handle = async ({ event, resolve }) => { const authHeader = event.request.headers.get('authorization'); if (authHeader?.startsWith('Bearer ')) { const token = authHeader.substring(7); try { const decoded = jwt.verify(token, JWT_SECRET); event.locals.user = decoded; } catch (error) { // Invalid token event.locals.user = null; } } return resolve(event); }; ``` #### 2. Create Credit Store ```typescript // src/lib/stores/credits.svelte.ts import { writable, derived } from 'svelte/store'; interface CreditState { balance: number; maxCreditLimit: number; loading: boolean; } function createCreditStore() { const { subscribe, set, update } = writable({ balance: 0, maxCreditLimit: 1000, loading: false }); return { subscribe, async fetchBalance() { update(state => ({ ...state, loading: true })); try { const response = await fetch('/api/credits/balance'); const data = await response.json(); set({ balance: data.balance, maxCreditLimit: data.maxCreditLimit, loading: false }); } catch (error) { console.error('Failed to fetch credits:', error); update(state => ({ ...state, loading: false })); } }, updateBalance(newBalance: number) { update(state => ({ ...state, balance: newBalance })); } }; } export const credits = createCreditStore(); ``` #### 3. API Route for Credits ```typescript // src/routes/api/credits/balance/+server.ts import type { RequestHandler } from './$types'; import { json, error } from '@sveltejs/kit'; export const GET: RequestHandler = async ({ locals, fetch }) => { if (!locals.user) { throw error(401, 'Unauthorized'); } const response = await fetch( `${process.env.MIDDLEWARE_URL}/credits/balance`, { headers: { 'Authorization': `Bearer ${locals.session?.accessToken}` } } ); if (!response.ok) { throw error(response.status, 'Failed to fetch credits'); } const data = await response.json(); return json(data); }; ``` #### 4. Usage in Component ```svelte
``` --- ### Backend Integration (NestJS) #### 1. Module Setup ```typescript // src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ManaCoreModule } from '@mana-core/nestjs-integration'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), ManaCoreModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ serviceKey: config.get('MANA_CORE_SERVICE_KEY'), baseUrl: config.get('MANA_CORE_URL'), }), inject: [ConfigService], }), ], }) export class AppModule {} ``` #### 2. Protected Controller ```typescript // src/decks/decks.controller.ts import { Controller, Post, Body, UseGuards } from '@nestjs/common'; import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration'; import { CreditClientService } from '@mana-core/nestjs-integration'; @Controller('api/decks') @UseGuards(AuthGuard) export class DecksController { constructor( private readonly decksService: DecksService, private readonly creditClient: CreditClientService, ) {} @Post() async createDeck( @CurrentUser() user: any, @Body() createDeckDto: CreateDeckDto, ) { const appId = 'manadeck'; const operation = 'DECK_CREATION'; // 1. Validate credits const validation = await this.creditClient.validateCredits( user.sub, appId, operation, ); if (!validation.hasCredits) { throw new BadRequestException({ error: 'insufficient_credits', message: `Insufficient credits. Required: ${validation.requiredAmount}, Available: ${validation.currentBalance}`, requiredAmount: validation.requiredAmount, currentBalance: validation.currentBalance, shortfall: validation.shortfall, }); } // 2. Create the deck const deck = await this.decksService.create(user.sub, createDeckDto); // 3. Deduct credits await this.creditClient.deductCredits( user.sub, appId, operation, `Created deck: ${deck.name}`, { deckId: deck.id }, ); return { success: true, deck, creditsUsed: validation.requiredAmount, }; } } ``` --- ## Migration Scripts ### Complete Migration SQL ```sql -- ============================================ -- Mana Core Database Schema -- Version: 1.0 -- Date: 2025-11-25 -- ============================================ -- Create schemas CREATE SCHEMA IF NOT EXISTS auth; CREATE SCHEMA IF NOT EXISTS credits; CREATE SCHEMA IF NOT EXISTS app_data; CREATE SCHEMA IF NOT EXISTS webhooks; -- ============================================ -- AUTH SCHEMA -- ============================================ -- 1. Users table CREATE TABLE auth.users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT UNIQUE NOT NULL, email_verified BOOLEAN DEFAULT false, email_verified_at TIMESTAMPTZ, name TEXT, image TEXT, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, deleted_at TIMESTAMPTZ, CONSTRAINT users_email_lowercase CHECK (email = LOWER(email)) ); CREATE INDEX idx_users_email ON auth.users(email) WHERE deleted_at IS NULL; CREATE INDEX idx_users_created_at ON auth.users(created_at); CREATE INDEX idx_users_deleted_at ON auth.users(deleted_at) WHERE deleted_at IS NOT NULL; -- 2. Accounts table CREATE TABLE auth.accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, provider TEXT NOT NULL, provider_account_id TEXT NOT NULL, access_token TEXT, refresh_token TEXT, expires_at TIMESTAMPTZ, token_type TEXT, scope TEXT, id_token TEXT, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, UNIQUE(provider, provider_account_id) ); CREATE INDEX idx_accounts_user_id ON auth.accounts(user_id); CREATE INDEX idx_accounts_provider ON auth.accounts(provider); CREATE UNIQUE INDEX idx_accounts_provider_account ON auth.accounts(provider, provider_account_id); -- 3. Sessions table CREATE TABLE auth.sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, session_token TEXT UNIQUE NOT NULL, refresh_token TEXT UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, last_active_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, device_id TEXT, device_name TEXT, device_type TEXT, platform TEXT, ip_address INET, user_agent TEXT, app_id TEXT NOT NULL, revoked BOOLEAN DEFAULT false, revoked_at TIMESTAMPTZ ); CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); CREATE INDEX idx_sessions_session_token ON auth.sessions(session_token) WHERE NOT revoked; CREATE INDEX idx_sessions_refresh_token ON auth.sessions(refresh_token) WHERE NOT revoked; CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); CREATE INDEX idx_sessions_app_id ON auth.sessions(app_id); CREATE INDEX idx_sessions_device_id ON auth.sessions(device_id); -- 4. Password reset tokens CREATE TABLE auth.password_reset_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, used_at TIMESTAMPTZ ); CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id); CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token) WHERE used_at IS NULL; CREATE INDEX idx_password_reset_tokens_expires_at ON auth.password_reset_tokens(expires_at); -- 5. Email verification tokens CREATE TABLE auth.email_verification_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, verified_at TIMESTAMPTZ ); CREATE INDEX idx_email_verification_tokens_user_id ON auth.email_verification_tokens(user_id); CREATE INDEX idx_email_verification_tokens_token ON auth.email_verification_tokens(token) WHERE verified_at IS NULL; CREATE INDEX idx_email_verification_tokens_expires_at ON auth.email_verification_tokens(expires_at); -- ============================================ -- CREDITS SCHEMA -- ============================================ -- 6. Credit balances CREATE TABLE credits.balances ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0), max_credit_limit INTEGER NOT NULL DEFAULT 1000, free_credits_remaining INTEGER NOT NULL DEFAULT 150, daily_free_credits INTEGER NOT NULL DEFAULT 5, last_daily_credit_at DATE, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, total_earned INTEGER DEFAULT 0, total_spent INTEGER DEFAULT 0, total_purchased INTEGER DEFAULT 0 ); CREATE INDEX idx_balances_balance ON credits.balances(balance); CREATE INDEX idx_balances_updated_at ON credits.balances(updated_at); -- 7. Transactions CREATE TABLE credits.transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, type TEXT NOT NULL, operation TEXT NOT NULL, amount INTEGER NOT NULL, balance_before INTEGER NOT NULL, balance_after INTEGER NOT NULL, app_id TEXT NOT NULL, description TEXT NOT NULL, metadata JSONB, reference_id TEXT, related_transaction_id UUID REFERENCES credits.transactions(id), created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, CHECK ( (type = 'usage' AND amount < 0) OR (type IN ('purchase', 'refund', 'admin_adjustment', 'daily_bonus', 'signup_bonus') AND amount > 0) OR (type = 'admin_adjustment') ) ); CREATE INDEX idx_transactions_user_id ON credits.transactions(user_id); CREATE INDEX idx_transactions_type ON credits.transactions(type); CREATE INDEX idx_transactions_app_id ON credits.transactions(app_id); CREATE INDEX idx_transactions_operation ON credits.transactions(operation); CREATE INDEX idx_transactions_created_at ON credits.transactions(created_at DESC); CREATE INDEX idx_transactions_reference_id ON credits.transactions(reference_id) WHERE reference_id IS NOT NULL; CREATE INDEX idx_transactions_metadata ON credits.transactions USING GIN(metadata); -- 8. Packages CREATE TABLE credits.packages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, credits INTEGER NOT NULL CHECK (credits > 0), price_cents INTEGER NOT NULL CHECK (price_cents >= 0), currency TEXT NOT NULL DEFAULT 'EUR', description TEXT, badge TEXT, sort_order INTEGER DEFAULT 0, active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); CREATE INDEX idx_packages_active ON credits.packages(active, sort_order); -- 9. Operation costs CREATE TABLE credits.operation_costs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id TEXT NOT NULL, operation TEXT NOT NULL, cost INTEGER NOT NULL CHECK (cost >= 0), display_name TEXT NOT NULL, description TEXT, active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, UNIQUE(app_id, operation) ); CREATE INDEX idx_operation_costs_app_id ON credits.operation_costs(app_id); CREATE INDEX idx_operation_costs_active ON credits.operation_costs(active); CREATE UNIQUE INDEX idx_operation_costs_app_operation ON credits.operation_costs(app_id, operation) WHERE active; -- ============================================ -- APP DATA SCHEMA -- ============================================ -- 10. User settings CREATE TABLE app_data.user_settings ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, app_id TEXT NOT NULL, settings JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, PRIMARY KEY (user_id, app_id) ); CREATE INDEX idx_user_settings_app_id ON app_data.user_settings(app_id); CREATE INDEX idx_user_settings_settings ON app_data.user_settings USING GIN(settings); -- ============================================ -- WEBHOOKS SCHEMA -- ============================================ -- 11. Webhook endpoints CREATE TABLE webhooks.endpoints ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id TEXT NOT NULL, url TEXT NOT NULL, secret TEXT NOT NULL, events TEXT[] NOT NULL DEFAULT '{credit.updated, credit.low_balance}', active BOOLEAN DEFAULT true, max_retries INTEGER DEFAULT 3, retry_delay_seconds INTEGER DEFAULT 60, last_success_at TIMESTAMPTZ, last_failure_at TIMESTAMPTZ, failure_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL ); CREATE INDEX idx_webhook_endpoints_app_id ON webhooks.endpoints(app_id); CREATE INDEX idx_webhook_endpoints_active ON webhooks.endpoints(active); -- 12. Webhook delivery log CREATE TABLE webhooks.delivery_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE, event_type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL, attempt_count INTEGER DEFAULT 0, response_status_code INTEGER, response_body TEXT, error_message TEXT, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, delivered_at TIMESTAMPTZ, next_retry_at TIMESTAMPTZ ); CREATE INDEX idx_delivery_log_endpoint_id ON webhooks.delivery_log(endpoint_id); CREATE INDEX idx_delivery_log_status ON webhooks.delivery_log(status); CREATE INDEX idx_delivery_log_created_at ON webhooks.delivery_log(created_at DESC); CREATE INDEX idx_delivery_log_next_retry ON webhooks.delivery_log(next_retry_at) WHERE status = 'retrying'; -- ============================================ -- FUNCTIONS AND TRIGGERS -- ============================================ -- Update timestamp trigger function (auth) CREATE OR REPLACE FUNCTION auth.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Update timestamp trigger function (credits) CREATE OR REPLACE FUNCTION credits.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Update timestamp trigger function (app_data) CREATE OR REPLACE FUNCTION app_data.update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Create balance for new users CREATE OR REPLACE FUNCTION credits.create_balance_for_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO credits.balances (user_id) VALUES (NEW.id) ON CONFLICT (user_id) DO NOTHING; RETURN NEW; END; $$ LANGUAGE plpgsql; -- Update session last active CREATE OR REPLACE FUNCTION auth.update_session_last_active() RETURNS TRIGGER AS $$ BEGIN NEW.last_active_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Apply triggers CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON auth.users FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON auth.accounts FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER update_balances_updated_at BEFORE UPDATE ON credits.balances FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); CREATE TRIGGER update_packages_updated_at BEFORE UPDATE ON credits.packages FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); CREATE TRIGGER update_operation_costs_updated_at BEFORE UPDATE ON credits.operation_costs FOR EACH ROW EXECUTE FUNCTION credits.update_updated_at_column(); CREATE TRIGGER update_user_settings_updated_at BEFORE UPDATE ON app_data.user_settings FOR EACH ROW EXECUTE FUNCTION app_data.update_updated_at_column(); CREATE TRIGGER create_balance_on_user_creation AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION credits.create_balance_for_new_user(); CREATE TRIGGER update_sessions_last_active BEFORE UPDATE ON auth.sessions FOR EACH ROW WHEN (OLD.session_token IS DISTINCT FROM NEW.session_token OR OLD.refresh_token IS DISTINCT FROM NEW.refresh_token) EXECUTE FUNCTION auth.update_session_last_active(); -- ============================================ -- SEED DATA -- ============================================ -- Credit packages INSERT INTO credits.packages (name, credits, price_cents, badge, sort_order) VALUES ('Starter Pack', 100, 99, NULL, 1), ('Power Pack', 500, 499, 'POPULAR', 2), ('Pro Pack', 1000, 899, 'BEST VALUE', 3), ('Ultimate Pack', 5000, 3999, NULL, 4); -- Operation costs for Manadeck INSERT INTO credits.operation_costs (app_id, operation, cost, display_name, description) VALUES ('manadeck', 'DECK_CREATION', 10, 'Create Deck', 'Create a new flashcard deck'), ('manadeck', 'CARD_CREATION', 2, 'Add Card', 'Add a single card to a deck'), ('manadeck', 'AI_CARD_GENERATION', 5, 'AI Card Generation', 'Generate a card using AI'), ('manadeck', 'DECK_EXPORT', 3, 'Export Deck', 'Export deck to various formats'); -- Operation costs for Maerchenzauber INSERT INTO credits.operation_costs (app_id, operation, cost, display_name, description) VALUES ('maerchenzauber', 'STORY_GENERATION', 50, 'Generate Story', 'Generate a new AI story'), ('maerchenzauber', 'CHARACTER_CREATION', 20, 'Create Character', 'Create a custom character'), ('maerchenzauber', 'IMAGE_GENERATION', 30, 'Generate Image', 'Generate story illustration'); -- Operation costs for Memoro INSERT INTO credits.operation_costs (app_id, operation, cost, display_name, description) VALUES ('memoro', 'TRANSCRIPTION_PER_HOUR', 120, 'Audio Transcription', 'Per hour of audio transcribed'), ('memoro', 'HEADLINE_GENERATION', 10, 'Generate Headline', 'AI-generated memo headline'), ('memoro', 'MEMORY_CREATION', 10, 'Create Memory', 'Generate memory from memo'), ('memoro', 'BLUEPRINT_PROCESSING', 5, 'Process Blueprint', 'Apply AI blueprint to memo'); -- Operation costs for Picture INSERT INTO credits.operation_costs (app_id, operation, cost, display_name, description) VALUES ('picture', 'IMAGE_GENERATION', 25, 'Generate Image', 'AI image generation'), ('picture', 'IMAGE_UPSCALE', 15, 'Upscale Image', 'Upscale image quality'), ('picture', 'STYLE_TRANSFER', 20, 'Style Transfer', 'Apply style to image'); -- ============================================ -- COMMENTS -- ============================================ COMMENT ON SCHEMA auth IS 'Authentication and user management'; COMMENT ON SCHEMA credits IS 'Credit system and transactions'; COMMENT ON SCHEMA app_data IS 'Application-specific user data'; COMMENT ON SCHEMA webhooks IS 'Webhook system for events'; COMMENT ON TABLE auth.users IS 'Core user identity table compatible with Better Auth'; COMMENT ON TABLE auth.accounts IS 'OAuth and social login provider accounts'; COMMENT ON TABLE auth.sessions IS 'Active user sessions for multi-device support'; COMMENT ON TABLE auth.password_reset_tokens IS 'Temporary tokens for password reset'; COMMENT ON TABLE auth.email_verification_tokens IS 'Tokens for email verification'; COMMENT ON TABLE credits.balances IS 'Current credit balance per user (single source of truth)'; COMMENT ON TABLE credits.transactions IS 'Complete audit trail of all credit operations'; COMMENT ON TABLE credits.packages IS 'Available credit packages for purchase'; COMMENT ON TABLE credits.operation_costs IS 'Credit costs per operation per app'; COMMENT ON TABLE app_data.user_settings IS 'Per-app user preferences and settings'; COMMENT ON TABLE webhooks.endpoints IS 'Registered webhooks for apps'; COMMENT ON TABLE webhooks.delivery_log IS 'Audit trail of webhook deliveries'; ``` --- ## Summary This design provides: 1. **Complete Database Schema**: Better Auth compatible, atomic transactions, audit trails 2. **RESTful API**: Authentication, user management, credits, admin endpoints 3. **Authentication Flows**: Email/password, OAuth (Google/Apple), token refresh 4. **Credit Transaction Logic**: Atomic operations, validation, purchases, daily bonuses 5. **Integration Patterns**: Mobile (React Native), Web (SvelteKit), Backend (NestJS) 6. **Migration Script**: Ready-to-execute SQL with all tables, indexes, triggers, and seed data The system is production-ready and designed for: - Scalability (indexed queries, efficient transactions) - Security (RLS policies, JWT validation) - Auditability (complete transaction history) - Flexibility (JSONB metadata, app-specific settings) - Multi-tenancy (app_id tracking throughout)