managarten/.hive-mind/central-auth-and-credits-design.md
2025-11-25 18:56:35 +01:00

76 KiB

Central Auth and Mana Credit System Design

Document Version: 1.0 Date: 2025-11-25 Status: Design Specification

Table of Contents

  1. Overview
  2. Database Schema
  3. API Architecture
  4. Authentication Flows
  5. Credit Transaction Logic
  6. Integration Patterns
  7. 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.

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.

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.

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.

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.

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).

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.

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.

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.

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.

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.

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.

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:

{
  "email": "user@example.com",
  "password": "SecurePass123!",
  "name": "John Doe",
  "deviceInfo": {
    "deviceId": "abc123",
    "deviceName": "iPhone 14",
    "deviceType": "ios",
    "platform": "mobile"
  }
}

Response (201 Created):

{
  "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:

{
  "email": "user@example.com",
  "password": "SecurePass123!",
  "deviceInfo": {
    "deviceId": "abc123",
    "deviceName": "iPhone 14",
    "deviceType": "ios",
    "platform": "mobile"
  }
}

Response (200 OK):

{
  "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:

{
  "refreshToken": "rt_...",
  "deviceInfo": {
    "deviceId": "abc123"
  }
}

Response (200 OK):

{
  "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:

{
  "refreshToken": "rt_..."
}

Response (204 No Content)


POST /auth/forgot-password

Request password reset.

Request:

{
  "email": "user@example.com"
}

Response (200 OK):

{
  "message": "Password reset email sent"
}

POST /auth/reset-password

Reset password with token.

Request:

{
  "token": "reset_token_...",
  "newPassword": "NewSecurePass123!"
}

Response (200 OK):

{
  "message": "Password reset successful"
}

Errors:

  • 400: Invalid or expired token
  • 400: Weak password

POST /auth/verify-email

Verify email with token.

Request:

{
  "token": "verify_token_..."
}

Response (200 OK):

{
  "message": "Email verified successfully"
}

POST /auth/google-signin

Sign in with Google OAuth.

Request:

{
  "token": "google_id_token...",
  "deviceInfo": {
    "deviceId": "abc123",
    "deviceName": "iPhone 14",
    "deviceType": "ios"
  }
}

Response (200 OK):

{
  "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:

{
  "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 <manaToken>

Response (200 OK):

{
  "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:

{
  "name": "Jane Doe",
  "image": "https://..."
}

Response (200 OK):

{
  "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):

{
  "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 <manaToken>

Response (200 OK):

{
  "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:

{
  "appId": "manadeck",
  "operation": "DECK_CREATION",
  "amount": 10
}

Response (200 OK):

{
  "hasCredits": true,
  "currentBalance": 150,
  "requiredAmount": 10,
  "balanceAfter": 140,
  "operationCost": 10
}

Response (400 Bad Request - Insufficient Credits):

{
  "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:

{
  "appId": "manadeck",
  "operation": "DECK_CREATION",
  "amount": 10,
  "description": "Created deck: Spanish Vocabulary",
  "metadata": {
    "deckId": "uuid",
    "deckName": "Spanish Vocabulary"
  }
}

Response (200 OK):

{
  "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):

{
  "success": true,
  "creditsAdded": 5,
  "newBalance": 155,
  "nextClaimAt": "2025-11-26T00:00:00Z"
}

Response (400 Bad Request - Already Claimed):

{
  "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):

{
  "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):

{
  "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:

{
  "packageId": "uuid",
  "paymentProvider": "stripe",
  "paymentIntentId": "pi_...",
  "amount": 499
}

Response (200 OK):

{
  "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):

{
  "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:

{
  "userId": "uuid",
  "amount": 100,
  "reason": "Compensation for service issue"
}

Response (200 OK):

{
  "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):

{
  "users": [...],
  "pagination": {
    "total": 1000,
    "limit": 50,
    "offset": 0
  }
}

PATCH /admin/operation-costs/:id

Update operation cost.

Request:

{
  "cost": 15
}

Response (200 OK):

{
  "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)

{
  "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)

{
  "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:

async function performCreditOperation(
  userId: string,
  appId: string,
  operation: string,
  description: string,
  metadata?: Record<string, any>
): Promise<TransactionResult> {
  // 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)

async function validateCredits(
  userId: string,
  appId: string,
  operation: string
): Promise<ValidationResult> {
  // 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

async function purchaseCredits(
  userId: string,
  packageId: string,
  paymentProvider: string,
  paymentReferenceId: string
): Promise<PurchaseResult> {
  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

async function claimDailyCredits(
  userId: string
): Promise<ClaimResult> {
  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

// 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

// features/credits/creditService.ts
export class CreditService {
  private readonly baseUrl: string;

  constructor() {
    this.baseUrl = process.env.EXPO_PUBLIC_MIDDLEWARE_API_URL!;
  }

  async getBalance(): Promise<CreditBalance> {
    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<ValidationResult> {
    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<string, any>
  ): Promise<TransactionResult> {
    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

// 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 (
    <View>
      {/* Your form UI */}

      <InsufficientCreditsModal
        visible={showInsufficientCredits}
        requiredCredits={creditError?.requiredAmount}
        availableCredits={creditError?.currentBalance}
        onClose={() => setShowInsufficientCredits(false)}
        onPurchase={() => navigation.navigate('CreditStore')}
      />
    </View>
  );
}

Web App Integration (SvelteKit)

1. Setup Server-Side Auth

// 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

// 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<CreditState>({
    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

// 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

<!-- src/routes/(app)/decks/create/+page.svelte -->
<script lang="ts">
  import { credits } from '$lib/stores/credits.svelte';
  import InsufficientCreditsModal from '$lib/components/InsufficientCreditsModal.svelte';

  let deckName = $state('');
  let loading = $state(false);
  let showInsufficientCredits = $state(false);

  async function handleSubmit() {
    loading = true;

    try {
      // Validate credits
      const validation = await fetch('/api/credits/validate', {
        method: 'POST',
        body: JSON.stringify({
          appId: 'manadeck',
          operation: 'DECK_CREATION'
        })
      });

      if (!validation.ok) {
        const error = await validation.json();
        if (error.error === 'insufficient_credits') {
          showInsufficientCredits = true;
          return;
        }
        throw new Error(error.message);
      }

      // Create deck
      const response = await fetch('/api/decks', {
        method: 'POST',
        body: JSON.stringify({ name: deckName })
      });

      const deck = await response.json();

      // Deduct credits
      await fetch('/api/credits/deduct', {
        method: 'POST',
        body: JSON.stringify({
          appId: 'manadeck',
          operation: 'DECK_CREATION',
          description: `Created deck: ${deckName}`,
          metadata: { deckId: deck.id }
        })
      });

      // Update local credit balance
      await credits.fetchBalance();

      // Navigate to deck
      goto(`/decks/${deck.id}`);

    } catch (error) {
      alert(error.message);
    } finally {
      loading = false;
    }
  }
</script>

<form onsubmit={handleSubmit}>
  <input bind:value={deckName} placeholder="Deck name" />
  <button disabled={loading}>Create Deck (10 credits)</button>
</form>

<InsufficientCreditsModal
  bind:visible={showInsufficientCredits}
  requiredCredits={10}
  currentCredits={$credits.balance}
/>

Backend Integration (NestJS)

1. Module Setup

// 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

// 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

-- ============================================
-- 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)