From 0e8f6f134e4b7f712c6561d51ffe1cdd6c5b6e5f Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:03:35 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs(credits):=20update=20docume?= =?UTF-8?q?ntation=20for=20simplified=20credit=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove free credits and signup bonus references - Remove B2B organization credits documentation - Update API responses (no freeCreditsRemaining, dailyFreeCredits) - Update environment variables (remove CREDITS_SIGNUP_BONUS, CREDITS_DAILY_FREE) - Update JWT info to reflect EdDSA via Better Auth - Simplify DATABASE_SCHEMA.md to B2C-only flow --- docs/ENVIRONMENT_VARIABLES.md | 2 - services/mana-core-auth/QUICKSTART.md | 61 +- services/mana-core-auth/README.md | 23 +- .../mana-core-auth/docs/DATABASE_SCHEMA.md | 647 +++++++----------- 4 files changed, 270 insertions(+), 463 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 47803088c..515de41ec 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -83,8 +83,6 @@ The generator reads `.env.development` and creates app-specific `.env` files wit | `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key | - | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | - | | `CORS_ORIGINS` | Allowed CORS origins | - | -| `CREDITS_SIGNUP_BONUS` | Credits on signup | `150` | -| `CREDITS_DAILY_FREE` | Daily free credits | `5` | | `RATE_LIMIT_TTL` | Rate limit window (seconds) | `60` | | `RATE_LIMIT_MAX` | Max requests per window | `100` | diff --git a/services/mana-core-auth/QUICKSTART.md b/services/mana-core-auth/QUICKSTART.md index 31da183d9..fe6352d94 100644 --- a/services/mana-core-auth/QUICKSTART.md +++ b/services/mana-core-auth/QUICKSTART.md @@ -9,38 +9,27 @@ Get the authentication system running in 5 minutes! - Docker & Docker Compose - OpenSSL (for key generation) -## Step 1: Generate JWT Keys (2 minutes) - -```bash -cd mana-core-auth -chmod +x scripts/generate-keys.sh -./scripts/generate-keys.sh -``` - -This will create `private.pem` and `public.pem` and show you the formatted keys for .env - -## Step 2: Configure Environment (1 minute) +## Step 1: Configure Environment (1 minute) ```bash # Copy the example cp .env.example .env # Edit .env and add: -# 1. JWT keys from Step 1 -# 2. Change default passwords -# 3. Add Stripe test keys (optional for now) +# 1. Change default passwords +# 2. Add Stripe test keys (optional for now) ``` **Minimum required changes in .env:** ```env -POSTGRES_PASSWORD=your-secure-password-here -REDIS_PASSWORD=your-redis-password-here -JWT_PRIVATE_KEY="your-private-key-here" -JWT_PUBLIC_KEY="your-public-key-here" +DATABASE_URL=postgresql://mana:mana@localhost:5432/mana_auth +REDIS_HOST=localhost ``` -## Step 3: Start Infrastructure (30 seconds) +Note: JWT keys are auto-generated by Better Auth (EdDSA algorithm) and stored in the database. + +## Step 2: Start Infrastructure (30 seconds) ```bash # From monorepo root @@ -50,7 +39,7 @@ docker-compose up postgres redis -d docker-compose ps ``` -## Step 4: Run Migrations (10 seconds) +## Step 3: Run Migrations (10 seconds) ```bash cd mana-core-auth @@ -64,7 +53,7 @@ Running migrations... Migrations completed successfully ``` -## Step 5: Start the Service (10 seconds) +## Step 4: Start the Service (10 seconds) ```bash pnpm start:dev @@ -143,15 +132,15 @@ Expected response: ```json { "balance": 0, - "freeCreditsRemaining": 150, "totalEarned": 0, - "totalSpent": 0, - "dailyFreeCredits": 5 + "totalSpent": 0 } ``` ### 4. Use Some Credits +First, you'll need to add credits via Stripe or a gift code. Then: + ```bash curl -X POST http://localhost:3001/api/v1/credits/use \ -H "Authorization: Bearer YOUR_TOKEN" \ @@ -175,14 +164,13 @@ Expected response: "type": "usage", "status": "completed", "amount": -10, - "balanceBefore": 150, - "balanceAfter": 140, + "balanceBefore": 100, + "balanceAfter": 90, "appId": "test", "description": "Test credit usage" }, "newBalance": { - "balance": 0, - "freeCreditsRemaining": 140, + "balance": 90, "totalSpent": 10 } } @@ -224,18 +212,18 @@ docker-compose ps # Check if postgres is healthy docker-compose logs postgres # Check logs ``` -### "JWT key not found" error +### "JWKS not found" error -**Problem:** JWT keys not set in .env +**Problem:** Better Auth hasn't initialized JWT keys yet **Solution:** ```bash -# Run the key generator again -./scripts/generate-keys.sh +# Make sure the database is running and migrations have been applied +pnpm db:push -# Copy the keys to .env -# Make sure they're properly escaped (with \n for newlines) +# The JWKS keys are auto-generated on first request +# Try making a login request to initialize them ``` ### Migrations fail @@ -328,8 +316,6 @@ pnpm db:studio ### Required - `DATABASE_URL` - PostgreSQL connection string -- `JWT_PRIVATE_KEY` - RS256 private key (PEM format) -- `JWT_PUBLIC_KEY` - RS256 public key (PEM format) ### Optional (have defaults) @@ -337,8 +323,7 @@ pnpm db:studio - `NODE_ENV` - Environment (default: development) - `REDIS_HOST` - Redis host (default: localhost) - `CORS_ORIGINS` - Allowed origins (default: localhost:3000,localhost:8081) -- `CREDITS_SIGNUP_BONUS` - Signup credits (default: 150) -- `CREDITS_DAILY_FREE` - Daily free credits (default: 5) +- `BASE_URL` - Base URL for JWKS (default: http://localhost:3001) ### For Production diff --git a/services/mana-core-auth/README.md b/services/mana-core-auth/README.md index d396c221c..a84007cab 100644 --- a/services/mana-core-auth/README.md +++ b/services/mana-core-auth/README.md @@ -4,19 +4,18 @@ Central authentication and credit management system for the Mana Universe ecosys ## Features -- **JWT-based Authentication** (RS256 algorithm) +- **JWT-based Authentication** (EdDSA algorithm via Better Auth) - User registration and login - Refresh token rotation - Multi-session management - - Device tracking + - JWKS endpoint for token verification - **Credit System** - User balance management - - Transaction ledger with double-entry bookkeeping + - Transaction ledger (purchase, usage, refund, gift) - Optimistic locking for concurrency - - Daily free credits - - Signup bonus (150 credits) - Idempotency for credit operations + - Gift code system with auto-redemption on registration - **Security** - Row-Level Security (RLS) on PostgreSQL @@ -145,7 +144,7 @@ Central authentication and credit management system for the Mana Universe ecosys - Get current credit balance - Requires: Bearer token -- Returns: `{ balance, freeCreditsRemaining, totalEarned, totalSpent }` +- Returns: `{ balance, totalEarned, totalSpent }` **POST** `/api/v1/credits/use` @@ -199,13 +198,10 @@ See `.env.example` for all available configuration options. Key variables: - `DATABASE_URL` - PostgreSQL connection string -- `JWT_PUBLIC_KEY` - RS256 public key (PEM format) -- `JWT_PRIVATE_KEY` - RS256 private key (PEM format) - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` - Redis configuration - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` - Stripe integration - `CORS_ORIGINS` - Allowed origins for CORS -- `CREDITS_SIGNUP_BONUS` - Free credits on signup (default: 150) -- `CREDITS_DAILY_FREE` - Daily free credits (default: 5) +- `BASE_URL` - Base URL for JWKS endpoint (e.g., http://localhost:3001) ## Development @@ -250,16 +246,15 @@ pnpm format ### Credit System -- **Signup Bonus**: 150 free credits on registration -- **Daily Free Credits**: 5 credits added every 24 hours - **Paid Credits**: Purchased via Stripe (100 mana = €1) -- **Usage Priority**: Free credits used first, then paid credits +- **Gift Codes**: Can be created and redeemed, auto-redeem on registration if pending +- **Transaction Types**: purchase, usage, refund, gift - **Idempotency**: Duplicate requests with same key are detected and ignored - **Concurrency**: Optimistic locking prevents race conditions ## Security Considerations -1. **JWT Keys**: Generate strong RS256 keys and keep private key secure +1. **JWT Keys**: Better Auth auto-generates EdDSA keys stored in `auth.jwks` table 2. **Database**: Use strong passwords and enable SSL in production 3. **Redis**: Always set a password for Redis 4. **CORS**: Only allow trusted origins diff --git a/services/mana-core-auth/docs/DATABASE_SCHEMA.md b/services/mana-core-auth/docs/DATABASE_SCHEMA.md index c2f653c5a..a0ca8c4e3 100644 --- a/services/mana-core-auth/docs/DATABASE_SCHEMA.md +++ b/services/mana-core-auth/docs/DATABASE_SCHEMA.md @@ -5,7 +5,7 @@ The Mana Core authentication service uses PostgreSQL with two main schemas: - `auth` - User authentication, sessions, and organization management -- `credits` - Credit system for B2C and B2B customers +- `credits` - Credit system for users ## Schema Diagrams @@ -14,11 +14,9 @@ The Mana Core authentication service uses PostgreSQL with two main schemas: ``` auth.users (UUID) ├── auth.sessions (user sessions) -├── auth.passwords (hashed passwords) -├── auth.accounts (OAuth providers) -├── auth.verification_tokens (email verification, password reset) -├── auth.two_factor_auth (2FA settings) -├── auth.security_events (audit log) +├── auth.accounts (OAuth providers + credentials) +├── auth.verifications (email verification, password reset) +├── auth.jwks (EdDSA keys for JWT signing) ├── auth.members (organization membership) ──┐ └── auth.invitations (org invitations) ───────┤ │ @@ -29,31 +27,74 @@ auth.organizations (TEXT) ←───────────────── ``` credits.balances (user credit balances) -├── credits.transactions (all credit movements) ──┐ -├── credits.purchases (credit purchases) │ -├── credits.usage_stats (analytics) │ -└── credits.packages (pricing tiers) │ - │ -credits.organization_balances ←───────────────────┤ -├── credits.credit_allocations (org→employee) │ -└── auth.organizations (TEXT) ────────────────────┘ +├── credits.transactions (all credit movements) +├── credits.purchases (credit purchases via Stripe) +├── credits.packages (pricing tiers) +└── credits.gift_codes (gift codes for sharing credits) ``` -## Better Auth Organization Plugin +## Core Tables -### Core Tables +### auth.users -#### auth.organizations - -Stores organization/company information for B2B customers. +Main user table managed by Better Auth. ```sql -CREATE TABLE auth.organizations ( - id TEXT PRIMARY KEY, -- Better Auth uses nanoid/ULID - name TEXT NOT NULL, -- Organization name - slug TEXT UNIQUE, -- URL-friendly identifier - logo TEXT, -- Logo URL - metadata JSONB, -- Additional custom data +CREATE TABLE auth.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + name TEXT, + email_verified BOOLEAN DEFAULT false, + image TEXT, + role TEXT DEFAULT 'user', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### auth.sessions + +Active user sessions. + +```sql +CREATE TABLE auth.sessions ( + id TEXT PRIMARY KEY, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### auth.jwks + +EdDSA keys for JWT signing (managed by Better Auth). + +```sql +CREATE TABLE auth.jwks ( + id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +## Credit Tables + +### credits.balances + +User credit balances with optimistic locking. + +```sql +CREATE TABLE credits.balances ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + balance INTEGER DEFAULT 0 NOT NULL, + total_earned INTEGER DEFAULT 0 NOT NULL, + total_spent INTEGER DEFAULT 0 NOT NULL, + version INTEGER DEFAULT 0 NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); @@ -61,406 +102,194 @@ CREATE TABLE auth.organizations ( **Key Design Decisions:** -- Uses TEXT for IDs (Better Auth requirement - nanoid/ULID format) -- Slug is unique and URL-friendly for organization pages -- Metadata field allows flexible custom attributes +- `balance`: Current available credits +- `total_earned`: Lifetime credits received (purchases + gifts) +- `total_spent`: Lifetime credits spent +- `version`: Enables optimistic locking to prevent race conditions -#### auth.members +### credits.transactions -Links users to organizations with roles (owner, admin, member). +Immutable ledger of all credit movements. + +```sql +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, -- 'purchase', 'usage', 'refund', 'gift' + status TEXT NOT NULL, -- 'pending', 'completed', 'failed' + amount INTEGER NOT NULL, -- Positive for credits in, negative for out + balance_before INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + app_id TEXT, -- Which app used credits + description TEXT, + idempotency_key TEXT UNIQUE, -- Prevent duplicate transactions + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX transactions_user_id_idx ON credits.transactions(user_id); +CREATE INDEX transactions_created_at_idx ON credits.transactions(created_at); +CREATE INDEX transactions_app_id_idx ON credits.transactions(app_id); +``` + +**Transaction Types:** + +| Type | Description | +|------|-------------| +| `purchase` | Credits bought via Stripe | +| `usage` | Credits spent in an app | +| `refund` | Credits returned (e.g., failed operation) | +| `gift` | Credits received via gift code | + +### credits.packages + +Available credit packages for purchase. + +```sql +CREATE TABLE credits.packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + credits INTEGER NOT NULL, + price_euro_cents INTEGER NOT NULL, + stripe_price_id TEXT, + active BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### credits.purchases + +Purchase history linked to Stripe. + +```sql +CREATE TABLE credits.purchases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + package_id UUID REFERENCES credits.packages(id), + credits INTEGER NOT NULL, + price_euro_cents INTEGER NOT NULL, + stripe_payment_intent_id TEXT, + stripe_checkout_session_id TEXT, + status TEXT NOT NULL, -- 'pending', 'completed', 'failed', 'refunded' + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### credits.gift_codes + +Gift codes for sharing credits. + +```sql +CREATE TABLE credits.gift_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + credits INTEGER NOT NULL, + created_by UUID REFERENCES auth.users(id), + redeemed_by UUID REFERENCES auth.users(id), + target_email TEXT, -- If set, only this email can redeem + expires_at TIMESTAMPTZ, + redeemed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +**Key Features:** + +- `target_email`: Pre-assign gift to specific email (auto-redeems on registration) +- `expires_at`: Optional expiration date +- `redeemed_by` + `redeemed_at`: Track redemption + +## Organization Tables (for Auth only) + +Organizations are used for team management, not credits. + +### auth.organizations + +```sql +CREATE TABLE auth.organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE, + logo TEXT, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### auth.members + +Links users to organizations with roles. ```sql CREATE TABLE auth.members ( id TEXT PRIMARY KEY, organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE, - user_id TEXT NOT NULL, -- References auth.users.id (UUID cast to TEXT) - role TEXT NOT NULL, -- 'owner', 'admin', 'member', or custom + user_id TEXT NOT NULL, + role TEXT NOT NULL, -- 'owner', 'admin', 'member' created_at TIMESTAMPTZ DEFAULT NOW() ); - -CREATE INDEX members_organization_id_idx ON auth.members(organization_id); -CREATE INDEX members_user_id_idx ON auth.members(user_id); -CREATE INDEX members_organization_user_idx ON auth.members(organization_id, user_id); ``` -**Key Design Decisions:** +## Optimistic Locking -- Composite index on (organization_id, user_id) for fast membership checks -- user_id is TEXT to match Better Auth expectations (actual data is UUID cast to TEXT) -- ON DELETE CASCADE ensures members are removed when org is deleted - -#### auth.invitations - -Tracks pending, accepted, and rejected organization invitations. - -```sql -CREATE TABLE auth.invitations ( - id TEXT PRIMARY KEY, - organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE, - email TEXT NOT NULL, -- Email of invitee - role TEXT NOT NULL, -- Role they'll have if accepted - status TEXT NOT NULL, -- 'pending', 'accepted', 'rejected', 'canceled' - expires_at TIMESTAMPTZ NOT NULL, -- Invitation expiry - inviter_id TEXT REFERENCES auth.users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX invitations_organization_id_idx ON auth.invitations(organization_id); -CREATE INDEX invitations_email_idx ON auth.invitations(email); -CREATE INDEX invitations_status_idx ON auth.invitations(status); -``` - -**Key Design Decisions:** - -- Index on email for quick lookup of pending invitations -- Index on status for filtering active invitations -- ON DELETE SET NULL for inviter (keeps history even if inviter deleted) -- expires_at allows automatic expiry of old invitations - -## Organization Credit Management - -### credits.organization_balances - -Tracks credit pools for B2B organizations. - -```sql -CREATE TABLE credits.organization_balances ( - organization_id TEXT PRIMARY KEY REFERENCES auth.organizations(id) ON DELETE CASCADE, - balance INTEGER DEFAULT 0 NOT NULL, -- Current available credits - allocated_credits INTEGER DEFAULT 0 NOT NULL, -- Sum of credits allocated to employees - available_credits INTEGER DEFAULT 0 NOT NULL, -- balance - allocated_credits - total_purchased INTEGER DEFAULT 0 NOT NULL, -- Total credits ever purchased - total_allocated INTEGER DEFAULT 0 NOT NULL, -- Total ever allocated (includes deallocated) - version INTEGER DEFAULT 0 NOT NULL, -- For optimistic locking - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); -``` - -**Key Design Decisions:** - -- `balance`: Organization's total purchased credits -- `allocated_credits`: Sum of credits allocated to employees (not yet spent) -- `available_credits`: Credits owner can still allocate (calculated: balance - allocated_credits) -- `total_purchased`: Historical tracking of all purchases -- `total_allocated`: Historical tracking (includes deallocations) -- `version`: Enables optimistic locking to prevent race conditions - -**Credit Flow:** - -1. Owner purchases credits → `balance` increases -2. Owner allocates to employee → `allocated_credits` increases, `available_credits` decreases -3. Employee spends credits → employee's `credits.balances.balance` decreases -4. Owner deallocates from employee → `allocated_credits` decreases, `available_credits` increases - -### credits.credit_allocations - -Immutable audit trail of all credit allocations. - -```sql -CREATE TABLE credits.credit_allocations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id TEXT REFERENCES auth.organizations(id) ON DELETE CASCADE, - employee_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, - amount INTEGER NOT NULL, -- Positive = allocation, negative = deallocation - allocated_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, - reason TEXT, -- Optional explanation - balance_before INTEGER NOT NULL, -- Employee balance before - balance_after INTEGER NOT NULL, -- Employee balance after - metadata JSONB, -- Additional context - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX credit_allocations_organization_id_idx ON credits.credit_allocations(organization_id); -CREATE INDEX credit_allocations_employee_id_idx ON credits.credit_allocations(employee_id); -CREATE INDEX credit_allocations_allocated_by_idx ON credits.credit_allocations(allocated_by); -CREATE INDEX credit_allocations_created_at_idx ON credits.credit_allocations(created_at); -``` - -**Key Design Decisions:** - -- **Immutable**: No updates or deletes allowed (audit trail) -- `amount` can be positive (allocation) or negative (deallocation/adjustment) -- `balance_before`/`balance_after` track exact state changes -- `allocated_by` tracks who made the change -- `reason` field for transparency and accountability - -### credits.transactions (Updated) - -Extended to support B2B transactions. - -```sql --- Added column: -organization_id TEXT REFERENCES auth.organizations(id) ON DELETE SET NULL - --- Added index: -CREATE INDEX transactions_organization_id_idx ON credits.transactions(organization_id); -``` - -**Key Design Decisions:** - -- `organization_id` is **nullable** (NULL for B2C users, set for B2B employees) -- ON DELETE SET NULL preserves transaction history even if org deleted -- Enables organization-wide usage analytics and reporting - -## ID Type Compatibility - -### The UUID vs TEXT Challenge - -**Problem:** - -- Better Auth uses TEXT IDs (nanoid/ULID format like "abc123xyz") -- Our existing system uses UUID for user IDs -- PostgreSQL doesn't allow direct foreign keys between UUID and TEXT - -**Solution:** -We use TEXT for organization-related tables and cast UUIDs to TEXT when needed: - -```sql --- members.user_id is TEXT (stores UUID cast to TEXT) -ALTER TABLE auth.members -ADD CONSTRAINT members_user_id_users_id_fk -FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; - --- This works because PostgreSQL can implicitly cast UUID to TEXT -``` - -**In Application Code:** +The `credits.balances` table uses a `version` column for optimistic locking: ```typescript -// When inserting a member -await db.insert(members).values({ - id: nanoid(), - organization_id: 'org_abc123', - user_id: userId.toString(), // Convert UUID to TEXT - role: 'member', -}); +// Prevent race conditions when using credits +const result = await db + .update(balances) + .set({ + balance: sql`balance - ${amount}`, + totalSpent: sql`total_spent + ${amount}`, + version: sql`version + 1`, + }) + .where( + and( + eq(balances.userId, userId), + eq(balances.version, currentVersion), + gte(balances.balance, amount) + ) + ); -// When querying -const member = await db.query.members.findFirst({ - where: eq(members.userId, userId.toString()), -}); -``` - -## Row Level Security (RLS) Policies - -### Helper Functions - -```sql --- Get user's role in organization -auth.user_organization_role(org_id TEXT) → TEXT - --- Check membership -auth.is_organization_member(org_id TEXT) → BOOLEAN -auth.is_organization_owner_or_admin(org_id TEXT) → BOOLEAN -auth.is_organization_owner(org_id TEXT) → BOOLEAN -``` - -### Key Policies - -**Organizations:** - -- Members can view their organizations -- Any user can create organizations (Better Auth adds them as owner) -- Only owners can update/delete organizations - -**Members:** - -- Members can view other members in their orgs -- Owners/admins can add/remove/update members -- Members can remove themselves - -**Invitations:** - -- Members can view org invitations -- Invitees can view invitations sent to them -- Owners/admins can create/manage invitations -- Inviters and invitees can delete invitations - -**Organization Balances:** - -- Members can view org balance -- Only owners can modify balances - -**Credit Allocations:** - -- Employees can view allocations to them -- Owners/admins can view all org allocations -- Only owners can create allocations -- **No updates/deletes** (immutable audit trail) - -## Migration Guide - -### Running Migrations - -```bash -# Generate migration from schema changes -pnpm run migration:generate - -# Run migrations -pnpm run migration:run - -# Or manually via SQL -psql $DATABASE_URL -f src/db/migrations/0001_better_auth_organizations.sql -``` - -### Migration Files - -**Up Migration:** `0001_better_auth_organizations.sql` - -- Creates organization tables -- Creates credit management tables -- Adds foreign keys and indexes -- Sets up triggers - -**Down Migration:** `0001_better_auth_organizations_down.sql` - -- Reverses all changes -- Safe rollback path - -**RLS Policies:** `postgres/init/03-organization-rls.sql` - -- Applied automatically in Docker -- Can be run manually: `psql $DATABASE_URL -f postgres/init/03-organization-rls.sql` - -## Data Migration Considerations - -### Existing Data - -If you have existing users and credit data: - -1. **Users**: No changes needed (remain B2C users) -2. **Balances**: No changes needed (personal balances) -3. **Transactions**: `organization_id` defaults to NULL (B2C) - -### New Organizations - -When creating a B2B organization: - -```sql --- 1. Create organization (Better Auth handles this) -INSERT INTO auth.organizations (id, name, slug) -VALUES ('org_abc123', 'Acme Corp', 'acme-corp'); - --- 2. Add owner as member (Better Auth handles this) -INSERT INTO auth.members (id, organization_id, user_id, role) -VALUES ('mem_xyz789', 'org_abc123', '', 'owner'); - --- 3. Create organization credit balance -INSERT INTO credits.organization_balances (organization_id) -VALUES ('org_abc123'); -``` - -## Performance Considerations - -### Indexes - -All critical query paths are indexed: - -- Organization lookups by slug -- Member lookups by user_id and organization_id -- Invitation lookups by email and status -- Credit allocation history by organization and employee - -### Optimistic Locking - -Both `credits.balances` and `credits.organization_balances` use a `version` column for optimistic locking: - -```typescript -// Prevent race conditions when allocating credits -await db - .update(organizationBalances) - .set({ - allocated_credits: sql`allocated_credits + ${amount}`, - version: sql`version + 1`, - }) - .where( - and( - eq(organizationBalances.organizationId, orgId), - eq(organizationBalances.version, currentVersion) - ) - ); -``` - -## Schema Relationships - -``` -B2C User Flow: -auth.users → credits.balances → credits.transactions - -B2B Owner Flow: -auth.users → auth.members → auth.organizations → credits.organization_balances - -B2B Employee Flow: -auth.users → auth.members → auth.organizations - ↓ -credits.balances ← credits.credit_allocations → credits.organization_balances - ↓ -credits.transactions (with organization_id) -``` - -## Future Enhancements - -### Planned Features - -1. **Usage Quotas**: Add limits per employee/organization -2. **Credit Expiry**: Time-based credit expiration for organizations -3. **Tiered Pricing**: Different rates for B2C vs B2B -4. **Sub-organizations**: Support for department-level credit pools -5. **Approval Workflows**: Multi-step approval for large allocations - -### Schema Extensions - -```sql --- Example: Usage quotas -ALTER TABLE credits.credit_allocations -ADD COLUMN quota_limit INTEGER, -ADD COLUMN quota_period TEXT; -- 'daily', 'weekly', 'monthly' - --- Example: Credit expiry -ALTER TABLE credits.organization_balances -ADD COLUMN credits_expire_at TIMESTAMPTZ; -``` - -## Troubleshooting - -### Common Issues - -**Foreign Key Errors (UUID vs TEXT):** - -```sql --- Check if casting is needed -SELECT user_id::uuid FROM auth.members WHERE user_id ~ '^[0-9a-f-]{36}$'; -``` - -**RLS Policy Blocking Queries:** - -```sql --- Temporarily disable RLS for debugging (development only!) -ALTER TABLE auth.organizations DISABLE ROW LEVEL SECURITY; - --- Check what policies apply -SELECT * FROM pg_policies WHERE tablename = 'organizations'; -``` - -**Optimistic Lock Failures:** - -```typescript -// Retry logic for version conflicts -const maxRetries = 3; -for (let i = 0; i < maxRetries; i++) { - try { - await allocateCredits(orgId, employeeId, amount); - break; - } catch (err) { - if (i === maxRetries - 1) throw err; - await sleep(100 * Math.pow(2, i)); // Exponential backoff - } +if (result.rowCount === 0) { + throw new Error('Concurrent modification or insufficient balance'); } ``` -## References +## Idempotency -- [Better Auth Organization Plugin](https://www.better-auth.com/docs/plugins/organization) -- [Drizzle ORM Documentation](https://orm.drizzle.team/) -- [PostgreSQL Row Level Security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) +The `idempotency_key` column in `credits.transactions` prevents duplicate operations: + +```typescript +// Check if transaction already exists +const existing = await db.query.transactions.findFirst({ + where: eq(transactions.idempotencyKey, idempotencyKey) +}); + +if (existing) { + return existing; // Return existing transaction, don't create duplicate +} +``` + +## Schema Files + +All database tables are defined in TypeScript using Drizzle ORM: + +``` +src/db/schema/ +├── auth.schema.ts # Users, sessions, accounts, jwks +├── organizations.schema.ts # Organizations, members, invitations +├── credits.schema.ts # Balances, transactions, packages, gifts +└── index.ts # Export all schemas +``` + +## Commands + +```bash +# Push schema to database (development) +pnpm db:push + +# Open Drizzle Studio to view/edit data +pnpm db:studio +```