managarten/.hive-mind/auth-research-report.md
2025-11-25 18:56:35 +01:00

2745 lines
74 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Central Authentication System Research Report
**Generated by:** Researcher Agent - Hive Mind System
**Date:** 2025-11-25
**Mission:** Comprehensive research on authentication technologies and best practices for central auth system
---
## Executive Summary
This report provides a comprehensive analysis of authentication technologies, security best practices, and architectural patterns for building a central authentication system for the Mana Universe monorepo ecosystem. The research covers modern auth libraries, PostgreSQL security patterns, credit/token system architectures, payment integration, and multi-app authentication strategies.
### Key Recommendations at a Glance
1. **Primary Auth Solution:** Better Auth (TypeScript-first, framework-agnostic)
2. **Database:** PostgreSQL with Row-Level Security (RLS) for multi-tenancy
3. **ORM:** Drizzle ORM (optimal for Better Auth integration)
4. **Payment Gateway:** Stripe with Connect for marketplace features
5. **JWT Strategy:** Short-lived access tokens (15min) + refresh token rotation
6. **Credit System:** Double-entry ledger pattern with idempotency keys
---
## 1. Authentication Library Comparison
### 1.1 Better Auth
**Overview:**
- Modern TypeScript-first authentication framework launched in 2024
- Framework-agnostic (React, Vue, Svelte, Astro, Next.js, Nuxt, SvelteKit, Hono)
- YC-backed (Y Combinator X25)
- Recommended by Next.js, Nuxt, Astro, and other major frameworks
**Key Features:**
- Email/password authentication
- 50+ OAuth providers (Google, GitHub, Discord, Twitter, etc.)
- Two-factor authentication (2FA) built-in
- Passkey support (WebAuthn)
- Magic link authentication
- Organization/multi-tenant management
- Multi-session support
- Device tracking and management
- Enterprise SSO capabilities
- Custom Identity Provider (IDP) creation
**Database Support:**
- PostgreSQL (via Drizzle or Prisma adapters)
- MySQL
- SQLite
- MongoDB
- Automatic schema generation via CLI
- Migration support built-in
**Plugin Architecture:**
- Official plugins: Organization management, 2FA
- Polar plugin for payments/subscriptions integration
- Extensible plugin ecosystem
**Pricing:**
- **100% FREE and open-source**
- No usage limits
- Self-hosted
- Full control over data
**Pros:**
- Comprehensive features out-of-the-box (no plugins needed for basics)
- Excellent TypeScript support with automatic type generation
- Automatic database schema generation/migration
- Framework-agnostic design
- Active development and YC backing
- Modern developer experience
- Perfect for monorepo architecture
- No vendor lock-in
**Cons:**
- Relatively new (2024) - less battle-tested than alternatives
- Smaller community compared to Auth.js
- Limited real-world production examples
- Documentation still growing
**Best For:**
- Modern TypeScript monorepos
- Teams prioritizing developer experience
- Projects needing multi-app authentication
- Cost-sensitive projects (100% free)
---
### 1.2 Auth.js (NextAuth.js)
**Overview:**
- Evolved from Next.js-specific to framework-agnostic
- Established open-source project
- Wide adoption in Next.js ecosystem
**Key Features:**
- 50+ built-in OAuth providers
- Extensive customization via callbacks
- JWT or database sessions
- Multiple database adapters (Prisma, Drizzle, Supabase)
**Pricing:**
- **FREE and open-source**
- Self-hosted
**Pros:**
- Battle-tested and mature
- Large community and ecosystem
- Extensive documentation and examples
- Great for OAuth provider breadth
- Works with Supabase via adapter
**Cons:**
- **Maintenance concerns:** Original developer abandoned project, one person maintaining 90% of work
- Advanced features (2FA) require custom implementation or additional packages
- More boilerplate for setup
- Less TypeScript-native than Better Auth
**Best For:**
- Teams already invested in Auth.js
- Projects heavily using OAuth providers
- Next.js-first applications
---
### 1.3 Supabase Auth
**Overview:**
- Integrated auth solution tied to Supabase ecosystem
- Built-in PostgreSQL integration with RLS
- Part of the Supabase backend-as-a-service
**Key Features:**
- JWT-based authentication
- Row-Level Security (RLS) integration
- OAuth providers
- Email/password authentication
- Magic links
- Phone authentication
**Pricing:**
- Free tier: 50,000 monthly active users
- Pro: $25/month for 100,000 users
- Includes PostgreSQL database with RLS
**Pros:**
- Automatic integration with PostgreSQL RLS
- Well-integrated with Supabase ecosystem
- Handles database user sync via triggers
- Good value for price
- Built-in security features
**Cons:**
- **Critical reliability issues reported:**
- Random user logouts
- No configurable session lifetime (fundamental missing feature)
- Unencrypted client-side token storage
- No 2FA on their own platform (concerning for security product)
- Vendor lock-in to Supabase ecosystem
- Poor documentation quality reported
- Unresponsive support channels
- Not ideal for custom auth requirements
**Best For:**
- Projects fully committed to Supabase ecosystem
- Simple auth requirements
- Budget-conscious projects with <100k users
---
### 1.4 Clerk
**Overview:**
- Commercial hosted authentication service
- Polished UI and developer experience
**Key Features:**
- Pre-built UI components
- Configurable session lifetime (up to 10 years)
- Organization management
- 2FA built-in
- Excellent Next.js integration
**Pricing:**
- Free tier: 10,000 monthly active users
- Business plan: **$550/month for 10,000 active users**
- Scales to $25,000-$60,000+ annually
**Pros:**
- Best-in-class developer experience
- Beautiful pre-built UI
- Excellent documentation
- Managed infrastructure
- No security expertise required
**Cons:**
- **Prohibitively expensive** for freemium/scaling apps
- Vendor lock-in
- Less control over authentication flow
- Not suitable for multi-app ecosystems with shared auth
**Best For:**
- Well-funded startups with budget
- Enterprise clients
- Teams wanting managed solution
---
### 1.5 Auth0
**Overview:**
- Enterprise identity platform
- Managed cloud service
- Comprehensive compliance features
**Key Features:**
- Enterprise SSO
- SAML integration
- Risk-based authentication
- Advanced security features
- Professional support
**Pricing:**
- Free tier: 25,000 B2C users
- Essentials: $35/month base + usage
- Scales to $30,000+ annually for enterprise
**Pros:**
- Enterprise-grade features
- Compliance certifications
- Professional support
- Advanced security capabilities
**Cons:**
- Expensive at scale
- Vendor lock-in
- Configuration happens in dashboard vs code
- Overkill for most projects
**Best For:**
- Large enterprises
- Projects requiring compliance certifications
- Organizations needing professional support
---
### 1.6 Comparison Matrix
| Feature | Better Auth | Auth.js | Supabase Auth | Clerk | Auth0 |
|---------|-------------|---------|---------------|-------|-------|
| **Pricing** | FREE | FREE | $25/mo (100k users) | $550/mo (10k users) | $35+/mo |
| **Framework Support** | Universal | Universal | Universal | Next.js-first | Universal |
| **TypeScript-First** | Yes | Partial | Partial | Yes | Partial |
| **2FA Built-in** | Yes | No | Limited | Yes | Yes |
| **Multi-Session** | Yes | Custom | Limited | Yes | Yes |
| **Multi-Tenancy** | Yes | Custom | Custom | Yes | Yes |
| **Auto Schema Gen** | Yes | No | Yes | N/A | N/A |
| **Self-Hosted** | Yes | Yes | Hybrid | No | No |
| **PostgreSQL RLS** | Compatible | Compatible | Native | No | No |
| **Monorepo-Friendly** | Excellent | Good | Good | Limited | Limited |
| **Vendor Lock-in** | None | None | High | High | High |
| **Maturity** | New (2024) | Mature | Mature | Mature | Mature |
| **Maintenance Risk** | Low | High | Medium | Low | Low |
| **Best For** | Modern monorepos | OAuth-heavy | Supabase projects | Enterprise ($$$) | Enterprise ($$$) |
---
## 2. PostgreSQL Security Best Practices
### 2.1 Authentication Methods
#### SCRAM-SHA-256 (Recommended)
- **Most secure** password-based authentication in PostgreSQL
- Uses Salted Challenge Response Authentication Mechanism
- SHA-256 hashing algorithm
- **Replace MD5 immediately** - MD5 is deprecated and insecure
#### Additional Methods
- **LDAP/PAM:** Centralized authentication for enterprise
- **Kerberos:** Strong authentication for enterprise environments
- **Certificate-based:** PKI authentication for services
- **GSSAPI:** Generic Security Services API support
#### Methods to AVOID
- **Trust authentication:** Never use in production (allows passwordless access)
- **MD5:** Deprecated, cryptographically broken
- **Password (plaintext):** Never use
---
### 2.2 Configuration Hardening
#### postgresql.conf
```sql
-- Network Configuration
listen_addresses = '10.0.0.5' -- Specific IP, not '*'
port = 5432 -- Consider non-standard port
-- Authentication
password_encryption = 'scram-sha-256' -- Force SCRAM
-- Connection Limits
max_connections = 100
superuser_reserved_connections = 3
-- Logging (Security Audit)
log_connections = on
log_disconnections = on
log_duration = on
log_line_prefix = '%t [%p]: user=%u,db=%d,app=%a,client=%h '
```
#### pg_hba.conf
```sql
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all all 10.0.0.0/24 scram-sha-256
hostssl all all 0.0.0.0/0 scram-sha-256
# Reject trust method entirely
# host all all 127.0.0.1/32 trust # NEVER USE
```
---
### 2.3 Access Control Best Practices
#### Principle of Least Privilege
```sql
-- Create application-specific roles
CREATE ROLE app_user LOGIN PASSWORD 'strong_password';
CREATE ROLE app_readonly LOGIN PASSWORD 'strong_password';
-- Grant minimal permissions
GRANT CONNECT ON DATABASE myapp TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO app_user;
GRANT SELECT ON TABLE users TO app_readonly;
-- Revoke public schema access
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
```
#### Avoid Shared Accounts
- Create **unique roles** for each user and application
- Never share database credentials across services
- Use different roles for different access levels
#### Password Policies
```sql
-- Set password expiration
ALTER ROLE app_user VALID UNTIL '2026-12-31';
-- Require strong passwords (enforce at application level)
-- Mix of uppercase, lowercase, numbers, special characters
-- Minimum 12-16 characters
```
---
### 2.4 Encryption & Transport Security
#### SSL/TLS for Connections
```sql
-- Enable SSL in postgresql.conf
ssl = on
ssl_cert_file = '/path/to/server.crt'
ssl_key_file = '/path/to/server.key'
ssl_ca_file = '/path/to/ca.crt'
-- Require SSL in pg_hba.conf
hostssl all all 0.0.0.0/0 scram-sha-256
```
#### Transparent Data Encryption (TDE)
- Encrypt data at rest using file system or volume encryption
- Consider pgcrypto extension for column-level encryption
---
### 2.5 Multi-Factor Authentication
PostgreSQL doesn't natively support 2FA, but you can implement it:
1. **Network Layer:** Use VPN with MFA requirement
2. **SSH Tunneling:** SSH with key + password/OTP
3. **Application Layer:** Implement 2FA in application before database connection
4. **Identity Provider:** Integrate with LDAP/AD that enforces MFA
---
### 2.6 Brute Force Protection
#### auth_delay Module
```sql
-- Install extension
CREATE EXTENSION auth_delay;
-- Adds delay on failed authentication attempts
-- Configured in postgresql.conf
auth_delay.milliseconds = 5000 -- 5 second delay
```
#### Connection Pooling Limits
- Use PgBouncer or similar to limit connection rates
- Implement application-level rate limiting
- Monitor failed authentication attempts
---
### 2.7 Audit & Monitoring
#### Essential Logging
```sql
-- Enable audit logging
log_statement = 'ddl' -- or 'all' for comprehensive logging
log_min_duration_statement = 1000 -- Log slow queries (ms)
-- Connection tracking
log_connections = on
log_disconnections = on
-- Failed attempts
log_error_verbosity = default
```
#### pgAudit Extension
```sql
CREATE EXTENSION pgaudit;
-- Configure audit logging
SET pgaudit.log = 'read, write, ddl';
SET pgaudit.log_catalog = off;
SET pgaudit.log_parameter = on;
```
---
### 2.8 Row-Level Security (RLS) for Multi-Tenancy
**See Section 4 for comprehensive RLS patterns**
Key principles:
- Enable RLS on all multi-tenant tables
- Create policies for SELECT, INSERT, UPDATE, DELETE
- Use JWT claims for tenant identification
- Test policies extensively with automated tests
---
### 2.9 Security Checklist
- [ ] Change default postgres superuser password immediately
- [ ] Use SCRAM-SHA-256 for all password authentication
- [ ] Disable trust authentication method
- [ ] Configure listen_addresses to specific IPs
- [ ] Enable SSL/TLS for all connections
- [ ] Create role-specific database users (no sharing)
- [ ] Apply principle of least privilege to all roles
- [ ] Enable connection and authentication logging
- [ ] Install and configure auth_delay extension
- [ ] Set up automated backups with encryption
- [ ] Subscribe to PostgreSQL security announcements
- [ ] Keep PostgreSQL updated to latest stable version
- [ ] Implement connection pooling with rate limits
- [ ] Configure pgAudit for compliance requirements
- [ ] Enable Row-Level Security for multi-tenant tables
- [ ] Regular security audits and penetration testing
---
## 3. JWT Security Best Practices
### 3.1 Token Expiration Strategy
#### Access Tokens
- **Recommended Lifespan:** 15-30 minutes
- **Rationale:** Short lifespan limits exposure if compromised
- **Storage:** Memory or httpOnly cookies (never localStorage)
#### Refresh Tokens
- **Recommended Lifespan:** 7-14 days
- **Rationale:** Balance between security and user experience
- **Storage:** httpOnly cookies (web) or secure storage (mobile)
```typescript
// Example token configuration
const tokenConfig = {
accessToken: {
expiresIn: '15m',
algorithm: 'RS256',
},
refreshToken: {
expiresIn: '7d',
rotating: true, // Implement token rotation
},
};
```
---
### 3.2 Refresh Token Rotation
**Critical Security Feature:**
- Every refresh token is **single-use only**
- New refresh token issued with each access token refresh
- Old refresh token immediately invalidated
- Detects token theft/replay attacks
```typescript
// Token rotation flow
async function refreshTokens(refreshToken: string) {
// 1. Validate refresh token
const session = await validateRefreshToken(refreshToken);
// 2. Check if token already used (replay attack detection)
if (session.refreshToken !== refreshToken) {
// Token reuse detected - revoke all user sessions
await revokeAllUserSessions(session.userId);
throw new Error('Token reuse detected');
}
// 3. Generate new token pair
const newAccessToken = generateAccessToken(session.userId);
const newRefreshToken = generateRefreshToken();
// 4. Update session with new refresh token
await updateSession(session.id, { refreshToken: newRefreshToken });
// 5. Return new tokens
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
```
**Benefits:**
- Blocks replay attacks
- Limits blast radius of stolen tokens
- Simplifies session management
- Industry standard in 2025
---
### 3.3 Algorithm Selection
#### RS256 (Recommended for Production)
- **Asymmetric encryption** (public/private key pair)
- Public key can verify tokens without risk
- Private key held securely by auth server only
- **Best for:** Microservices, distributed systems, multi-app ecosystems
```typescript
// RS256 configuration
const jwtConfig = {
algorithm: 'RS256',
privateKey: fs.readFileSync('private.key'),
publicKey: fs.readFileSync('public.key'),
issuer: 'manacore-auth',
audience: ['manacore', 'maerchenzauber', 'memoro', 'picture'],
};
```
#### HS256 (Acceptable for Monoliths)
- **Symmetric encryption** (single secret key)
- Same key for signing and verification
- Simpler setup
- **Risk:** Any service with the key can create valid tokens
#### NEVER USE
- **None algorithm:** Allows unsigned tokens (security disaster)
- **Weak algorithms:** HS256 with weak secrets
---
### 3.4 Claims Validation
#### Standard Claims (MUST Validate)
```typescript
interface StandardClaims {
iss: string; // Issuer - must match your auth server
sub: string; // Subject - user ID
aud: string | string[]; // Audience - target application(s)
exp: number; // Expiration time - MUST validate
iat: number; // Issued at - detect old tokens
nbf?: number; // Not before - prevent premature use
jti?: string; // JWT ID - for revocation tracking
}
// Validation example
function validateToken(token: string) {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'manacore-auth',
audience: currentApp,
clockTolerance: 30, // 30 seconds for clock skew
});
// Additional validation
if (decoded.iat && decoded.iat > Date.now() / 1000) {
throw new Error('Token issued in the future');
}
return decoded;
}
```
#### Custom Claims Best Practices
**The 90/10 Rule:**
- Only include **frequently used claims** (90% of requests need them)
- Keep claims minimal to reduce token size
- Avoid sensitive data in JWT payload
```typescript
// Good: Minimal custom claims
interface AppClaims extends StandardClaims {
app_id: string; // Which app is this for?
role: 'user' | 'admin'; // User role
tenant_id?: string; // For multi-tenant apps
}
// Bad: Too much data
interface BadClaims extends StandardClaims {
email: string; // PII - not needed in most requests
full_name: string; // PII - fetch from database when needed
address: object; // Large object - bloats token
preferences: object; // Rarely needed - fetch separately
profile_pic: string; // Large data - serve via CDN
}
```
---
### 3.5 app_metadata vs user_metadata
**Pattern from Auth0/Supabase:**
```typescript
// app_metadata: System-controlled, security-sensitive
interface AppMetadata {
role: 'user' | 'admin' | 'moderator';
tenant_id: string;
subscription: 'free' | 'pro' | 'enterprise';
credits: number;
flags: string[]; // Feature flags
}
// user_metadata: User-controlled, non-sensitive
interface UserMetadata {
display_name: string;
avatar_url: string;
preferences: {
theme: 'light' | 'dark';
language: string;
};
}
// In JWT, only include security-critical app_metadata
interface JWTPayload {
sub: string;
app_metadata: {
role: string;
tenant_id: string;
};
// user_metadata fetched from database when needed
}
```
**Namespacing Custom Claims:**
```typescript
// Use namespaced claims to avoid conflicts
interface NamespacedClaims {
'https://manacore.ai/app_id': string;
'https://manacore.ai/role': string;
'https://manacore.ai/tenant_id': string;
}
```
---
### 3.6 Storage Best Practices
#### ❌ NEVER Store in localStorage
```typescript
// VULNERABLE TO XSS ATTACKS
localStorage.setItem('token', accessToken); // DON'T DO THIS
```
#### ✅ Web Applications
```typescript
// Option 1: httpOnly cookies (best for web)
res.cookie('accessToken', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
// Option 2: Memory only (for SPA)
// Store in closure or React state, lost on refresh
let accessToken = null;
```
#### ✅ Mobile Applications
```typescript
// Expo SecureStore (encrypted storage)
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('accessToken', token);
const token = await SecureStore.getItemAsync('accessToken');
```
---
### 3.7 Transport Security
```typescript
// ONLY send tokens over HTTPS
if (window.location.protocol !== 'https:' && !isDevelopment) {
throw new Error('JWT must be transmitted over HTTPS');
}
// Include in Authorization header
const headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
// NEVER include in URL query parameters
// ❌ BAD: https://api.example.com/users?token=eyJ...
// ✅ GOOD: Authorization: Bearer eyJ...
```
---
### 3.8 Token Revocation Strategy
JWTs are stateless, making revocation challenging. Solutions:
#### 1. Short Expiration + Refresh Tokens
- Access tokens expire quickly (15min)
- Revoke refresh tokens in database
- Compromised access token only valid briefly
#### 2. Token Blacklist (jti claim)
```typescript
// Add unique ID to each token
const token = jwt.sign(
{ sub: userId, jti: uuid() },
privateKey
);
// Check blacklist on each request
async function validateToken(token: string) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token revoked');
}
return decoded;
}
// Revoke token
async function revokeToken(jti: string, expiresAt: Date) {
const ttl = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
await redis.setex(`blacklist:${jti}`, ttl, '1');
}
```
#### 3. Session Table (Hybrid Approach)
```sql
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
refresh_token TEXT NOT NULL,
access_token_jti TEXT,
device_info JSONB,
ip_address INET,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT FALSE
);
-- Quick revocation check
CREATE INDEX idx_sessions_jti ON sessions(access_token_jti) WHERE revoked = FALSE;
```
---
### 3.9 Security Checklist
- [ ] Use RS256 algorithm for distributed systems
- [ ] Set access token expiration to 15-30 minutes
- [ ] Implement refresh token rotation
- [ ] Validate all standard claims (iss, aud, exp, iat, nbf)
- [ ] Store tokens in httpOnly cookies (web) or secure storage (mobile)
- [ ] NEVER store tokens in localStorage
- [ ] Transmit tokens only over HTTPS
- [ ] Keep JWT payload minimal (90/10 rule)
- [ ] Namespace custom claims to avoid conflicts
- [ ] Implement token revocation strategy
- [ ] Monitor for token reuse (replay attacks)
- [ ] Include device fingerprinting for suspicious activity
- [ ] Set up automated token cleanup/expiration
---
## 4. PostgreSQL Row-Level Security (RLS) for Multi-Tenancy
### 4.1 Overview
**Row-Level Security (RLS)** is a PostgreSQL feature (since 9.5) that allows fine-grained access control at the row level. It's essential for multi-tenant SaaS applications.
**Key Benefit:** Defense in depth - even if application code has bugs, database won't return data outside tenant scope.
---
### 4.2 Basic RLS Setup
```sql
-- 1. Create multi-tenant table
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 2. Enable RLS on table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 3. Create policy for SELECT
CREATE POLICY tenant_isolation_select ON posts
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 4. Create policy for INSERT
CREATE POLICY tenant_isolation_insert ON posts
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 5. Create policy for UPDATE
CREATE POLICY tenant_isolation_update ON posts
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 6. Create policy for DELETE
CREATE POLICY tenant_isolation_delete ON posts
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
```
---
### 4.3 Setting Tenant Context from JWT
```typescript
// Application code (NestJS example)
export class TenantMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
// 1. Extract JWT from request
const token = req.headers.authorization?.replace('Bearer ', '');
// 2. Verify and decode JWT
const decoded = jwt.verify(token, publicKey);
// 3. Set tenant context for database queries
await this.dataSource.query(
`SET LOCAL app.current_tenant_id = $1`,
[decoded.tenant_id]
);
// 4. Store in request for application use
req.tenantId = decoded.tenant_id;
req.userId = decoded.sub;
next();
}
}
// Alternative: Set at connection level for entire session
async function createTenantConnection(tenantId: string) {
const connection = await pool.connect();
await connection.query(`SET app.current_tenant_id = $1`, [tenantId]);
return connection;
}
```
---
### 4.4 RLS with Supabase Integration
```sql
-- Supabase automatically sets auth.uid() from JWT
-- Use auth.uid() for user-level isolation
CREATE POLICY user_own_data ON posts
FOR ALL
USING (user_id = auth.uid());
-- Combine with tenant isolation
CREATE POLICY tenant_and_user_isolation ON posts
FOR ALL
USING (
tenant_id = current_setting('app.current_tenant_id', true)::UUID
AND user_id = auth.uid()
);
```
---
### 4.5 Advanced RLS Patterns
#### Role-Based Access Control (RBAC)
```sql
-- Create roles table
CREATE TABLE tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
UNIQUE(tenant_id, user_id)
);
-- Enable RLS on tenant_users
ALTER TABLE tenant_users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_users_select ON tenant_users
FOR SELECT
USING (user_id = auth.uid());
-- Policy allowing admins to see all posts, members only their own
CREATE POLICY posts_rbac ON posts
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id', true)::UUID
AND (
-- User is admin or owner
EXISTS (
SELECT 1 FROM tenant_users
WHERE tenant_id = posts.tenant_id
AND user_id = auth.uid()
AND role IN ('owner', 'admin')
)
-- OR user owns the post
OR user_id = auth.uid()
)
);
```
#### Shared Resources Across Tenants
```sql
-- Some resources might be shared (e.g., public templates)
CREATE TABLE templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL for public templates
name TEXT NOT NULL,
is_public BOOLEAN DEFAULT FALSE
);
CREATE POLICY templates_access ON templates
FOR SELECT
USING (
is_public = TRUE -- Public templates accessible to all
OR tenant_id = current_setting('app.current_tenant_id', true)::UUID
);
```
---
### 4.6 Performance Considerations
#### Indexing for RLS
```sql
-- CRITICAL: Index tenant_id for RLS performance
CREATE INDEX idx_posts_tenant_id ON posts(tenant_id);
-- Composite indexes for common query patterns
CREATE INDEX idx_posts_tenant_user ON posts(tenant_id, user_id);
CREATE INDEX idx_posts_tenant_created ON posts(tenant_id, created_at DESC);
```
#### Policy Combining (OR vs AND)
```sql
-- By default, multiple policies are combined with OR
-- Policy 1: User can see their own posts
CREATE POLICY user_own_posts ON posts
FOR SELECT
USING (user_id = auth.uid());
-- Policy 2: Admins can see all tenant posts
CREATE POLICY admin_all_posts ON posts
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM tenant_users
WHERE tenant_id = posts.tenant_id
AND user_id = auth.uid()
AND role = 'admin'
)
);
-- Result: User sees their posts OR all posts if admin (OR logic)
```
---
### 4.7 Testing RLS Policies
**CRITICAL:** Extensively test RLS policies to prevent data leaks.
```typescript
// Integration test example
describe('RLS Tenant Isolation', () => {
it('should not allow user from tenant A to see tenant B data', async () => {
// Create data for tenant A
const tenantAUser = await createUser({ tenantId: 'tenant-a' });
const tenantAPost = await createPost({
tenantId: 'tenant-a',
userId: tenantAUser.id
});
// Create user for tenant B
const tenantBUser = await createUser({ tenantId: 'tenant-b' });
// Authenticate as tenant B user
const tenantBToken = generateToken(tenantBUser);
// Attempt to access tenant A data
const response = await request(app)
.get(`/posts/${tenantAPost.id}`)
.set('Authorization', `Bearer ${tenantBToken}`);
// Should be forbidden or not found
expect(response.status).toBe(404); // Or 403
});
it('should allow admin to see all tenant posts', async () => {
const admin = await createUser({ tenantId: 'tenant-a', role: 'admin' });
const member = await createUser({ tenantId: 'tenant-a', role: 'member' });
const memberPost = await createPost({
tenantId: 'tenant-a',
userId: member.id
});
const adminToken = generateToken(admin);
const response = await request(app)
.get(`/posts/${memberPost.id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
});
});
```
---
### 4.8 Views and RLS Bypass Risk
**CRITICAL SECURITY WARNING:**
```sql
-- Views can bypass RLS if owned by superuser!
CREATE VIEW all_posts AS SELECT * FROM posts;
-- If view owner has BYPASSRLS privilege, RLS is ignored
ALTER VIEW all_posts OWNER TO postgres; -- DANGEROUS
-- Solution: Views should be owned by role without BYPASSRLS
CREATE ROLE app_viewer;
ALTER VIEW all_posts OWNER TO app_viewer;
-- Or use security barrier views
CREATE VIEW safe_posts WITH (security_barrier) AS
SELECT * FROM posts;
```
---
### 4.9 RLS Best Practices
- [ ] Enable RLS on ALL multi-tenant tables
- [ ] Create policies for SELECT, INSERT, UPDATE, DELETE separately
- [ ] Index tenant_id columns for performance
- [ ] Use runtime configuration variables for tenant context
- [ ] Combine RLS with application-level validation (defense in depth)
- [ ] Test policies extensively with integration tests
- [ ] Avoid views owned by superuser (bypasses RLS)
- [ ] Use security barrier views when needed
- [ ] Monitor RLS policy performance
- [ ] Document policies and their intended behavior
- [ ] Regular security audits of RLS policies
- [ ] Use connection pooling with proper tenant context setting
---
## 5. Credit/Token System Architecture
### 5.1 Core Requirements
A robust credit system for multi-app ecosystems needs:
- **Accuracy:** Financial precision (no rounding errors)
- **Auditability:** Complete transaction history
- **Idempotency:** No duplicate charges
- **Atomicity:** All-or-nothing transactions
- **Performance:** Fast balance checks
- **Security:** Prevent unauthorized access
- **Scalability:** Handle high transaction volume
---
### 5.2 Database Schema - Ledger Pattern
**Double-Entry Accounting** is the gold standard for financial systems.
```sql
-- 1. Accounts table (user wallets)
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
account_type TEXT NOT NULL CHECK (account_type IN ('credit', 'bonus', 'pending')),
balance DECIMAL(20, 2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
currency TEXT NOT NULL DEFAULT 'USD',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, account_type, currency)
);
-- 2. Transactions table (ledger)
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
idempotency_key TEXT UNIQUE NOT NULL, -- Critical for preventing duplicates
transaction_type TEXT NOT NULL CHECK (
transaction_type IN (
'credit_purchase', 'credit_usage', 'bonus_grant',
'refund', 'adjustment', 'expiration'
)
),
amount DECIMAL(20, 2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'completed', 'failed', 'reversed')
),
metadata JSONB, -- Store app_id, feature_id, stripe_payment_id, etc.
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
expires_at TIMESTAMP,
-- Double-entry references
debit_account_id UUID REFERENCES accounts(id),
credit_account_id UUID REFERENCES accounts(id),
-- Audit trail
created_by UUID REFERENCES auth.users(id),
notes TEXT
);
-- 3. Transaction entries (double-entry records)
CREATE TABLE transaction_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES accounts(id),
entry_type TEXT NOT NULL CHECK (entry_type IN ('debit', 'credit')),
amount DECIMAL(20, 2) NOT NULL,
balance_after DECIMAL(20, 2) NOT NULL, -- Snapshot for auditing
created_at TIMESTAMP DEFAULT NOW()
);
-- 4. Indexes
CREATE INDEX idx_accounts_user ON accounts(user_id);
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_status ON transactions(status);
CREATE INDEX idx_transactions_idempotency ON transactions(idempotency_key);
CREATE INDEX idx_entries_transaction ON transaction_entries(transaction_id);
CREATE INDEX idx_entries_account ON transaction_entries(account_id);
```
---
### 5.3 Idempotency Implementation
**Critical for preventing duplicate charges:**
```typescript
// Client generates idempotency key
import { v4 as uuidv4 } from 'uuid';
async function purchaseCredits(userId: string, amount: number) {
const idempotencyKey = uuidv4(); // Or use request ID
return await fetch('/api/credits/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount }),
});
}
// Server-side handler
async function handleCreditPurchase(
userId: string,
amount: number,
idempotencyKey: string
) {
// 1. Check if transaction already exists
const existing = await db.query(
'SELECT * FROM transactions WHERE idempotency_key = $1',
[idempotencyKey]
);
if (existing.rows.length > 0) {
// Transaction already processed - return existing result
return {
status: existing.rows[0].status,
transactionId: existing.rows[0].id,
cached: true,
};
}
// 2. Begin database transaction
await db.query('BEGIN');
try {
// 3. Create transaction record (locks via idempotency_key UNIQUE)
const transaction = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type,
amount, status
) VALUES ($1, $2, 'credit_purchase', $3, 'pending')
RETURNING *`,
[userId, idempotencyKey, amount]
);
// 4. Process Stripe payment
const payment = await stripe.paymentIntents.create({
amount: amount * 100, // Stripe uses cents
currency: 'usd',
metadata: {
transaction_id: transaction.rows[0].id,
user_id: userId,
},
});
// 5. Update transaction status
await db.query(
'UPDATE transactions SET status = $1, completed_at = NOW() WHERE id = $2',
['completed', transaction.rows[0].id]
);
// 6. Credit user account
await creditUserAccount(userId, amount, transaction.rows[0].id);
// 7. Commit
await db.query('COMMIT');
return {
status: 'completed',
transactionId: transaction.rows[0].id,
};
} catch (error) {
// 8. Rollback on error
await db.query('ROLLBACK');
// Mark transaction as failed
await db.query(
'UPDATE transactions SET status = $1 WHERE idempotency_key = $2',
['failed', idempotencyKey]
);
throw error;
}
}
```
---
### 5.4 Double-Entry Bookkeeping
```typescript
// Credit user account (double-entry)
async function creditUserAccount(
userId: string,
amount: number,
transactionId: string
) {
await db.query('BEGIN');
try {
// 1. Get or create user credit account
const account = await db.query(
`INSERT INTO accounts (user_id, account_type, balance)
VALUES ($1, 'credit', 0)
ON CONFLICT (user_id, account_type, currency)
DO UPDATE SET updated_at = NOW()
RETURNING *`,
[userId]
);
// 2. Lock account for update
await db.query(
'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
[account.rows[0].id]
);
// 3. Update balance
const newBalance = await db.query(
`UPDATE accounts
SET balance = balance + $1, updated_at = NOW()
WHERE id = $2
RETURNING balance`,
[amount, account.rows[0].id]
);
// 4. Create credit entry
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'credit', $3, $4)`,
[transactionId, account.rows[0].id, amount, newBalance.rows[0].balance]
);
// 5. Create corresponding debit entry (from system account)
const systemAccount = await getSystemAccount();
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'debit', $3, 0)`,
[transactionId, systemAccount.id, amount]
);
await db.query('COMMIT');
return newBalance.rows[0].balance;
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
// Debit user account (use credits)
async function debitUserAccount(
userId: string,
amount: number,
appId: string,
featureId: string
) {
const idempotencyKey = `${userId}-${appId}-${featureId}-${Date.now()}`;
await db.query('BEGIN');
try {
// 1. Check sufficient balance
const account = await db.query(
'SELECT * FROM accounts WHERE user_id = $1 AND account_type = $2 FOR UPDATE',
[userId, 'credit']
);
if (!account.rows.length || account.rows[0].balance < amount) {
throw new Error('Insufficient credits');
}
// 2. Create transaction
const transaction = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'credit_usage', $3, 'completed', $4)
RETURNING *`,
[
userId,
idempotencyKey,
amount,
JSON.stringify({ app_id: appId, feature_id: featureId })
]
);
// 3. Debit account
const newBalance = await db.query(
`UPDATE accounts
SET balance = balance - $1, updated_at = NOW()
WHERE id = $2
RETURNING balance`,
[amount, account.rows[0].id]
);
// 4. Create debit entry
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'debit', $3, $4)`,
[transaction.rows[0].id, account.rows[0].id, amount, newBalance.rows[0].balance]
);
await db.query('COMMIT');
return {
success: true,
newBalance: newBalance.rows[0].balance,
transactionId: transaction.rows[0].id,
};
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
```
---
### 5.5 Balance Calculation Strategies
#### Strategy 1: Cached Balance (Recommended)
```sql
-- Store balance in accounts table
-- Update on each transaction
-- Fast reads, consistent with double-entry
SELECT balance FROM accounts
WHERE user_id = $1 AND account_type = 'credit';
```
#### Strategy 2: Calculated Balance
```sql
-- Calculate from transaction entries
-- Slower but always accurate
-- Good for auditing
SELECT
COALESCE(SUM(
CASE
WHEN entry_type = 'credit' THEN amount
WHEN entry_type = 'debit' THEN -amount
END
), 0) as balance
FROM transaction_entries te
JOIN transactions t ON te.transaction_id = t.id
WHERE t.user_id = $1 AND t.status = 'completed';
```
#### Strategy 3: Hybrid (Best Practice)
```typescript
// Use cached balance for speed
// Periodically verify against calculated balance
async function verifyAccountBalance(userId: string) {
const cached = await getCachedBalance(userId);
const calculated = await calculateBalance(userId);
if (Math.abs(cached - calculated) > 0.01) {
// Balance mismatch - alert and reconcile
await alertBalanceMismatch(userId, cached, calculated);
await reconcileBalance(userId, calculated);
}
}
```
---
### 5.6 Credit Types & Expiration
```sql
-- Support multiple credit types
CREATE TABLE credit_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
expires_after INTERVAL, -- e.g., '90 days'
priority INTEGER DEFAULT 0, -- Lower priority used first
can_refund BOOLEAN DEFAULT TRUE
);
-- Track expiration per credit batch
CREATE TABLE credit_balances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
credit_type_id UUID NOT NULL REFERENCES credit_types(id),
amount DECIMAL(20, 2) NOT NULL,
remaining DECIMAL(20, 2) NOT NULL,
granted_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
transaction_id UUID REFERENCES transactions(id)
);
-- Use credits with expiration priority
CREATE FUNCTION use_credits(
p_user_id UUID,
p_amount DECIMAL
) RETURNS VOID AS $$
DECLARE
v_credit_balance RECORD;
v_remaining DECIMAL := p_amount;
v_to_deduct DECIMAL;
BEGIN
-- Use credits in priority order (expiring first)
FOR v_credit_balance IN
SELECT * FROM credit_balances
WHERE user_id = p_user_id
AND remaining > 0
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY
CASE WHEN expires_at IS NULL THEN 1 ELSE 0 END, -- Non-expiring last
expires_at ASC, -- Expiring soon first
granted_at ASC -- Older first
FOR UPDATE
LOOP
EXIT WHEN v_remaining <= 0;
v_to_deduct := LEAST(v_credit_balance.remaining, v_remaining);
UPDATE credit_balances
SET remaining = remaining - v_to_deduct
WHERE id = v_credit_balance.id;
v_remaining := v_remaining - v_to_deduct;
END LOOP;
IF v_remaining > 0 THEN
RAISE EXCEPTION 'Insufficient credits';
END IF;
END;
$$ LANGUAGE plpgsql;
```
---
### 5.7 Refunds & Reversals
```typescript
async function refundTransaction(
transactionId: string,
reason: string,
partialAmount?: number
) {
await db.query('BEGIN');
try {
// 1. Get original transaction
const original = await db.query(
'SELECT * FROM transactions WHERE id = $1 FOR UPDATE',
[transactionId]
);
if (!original.rows.length) {
throw new Error('Transaction not found');
}
if (original.rows[0].status !== 'completed') {
throw new Error('Can only refund completed transactions');
}
const refundAmount = partialAmount || original.rows[0].amount;
// 2. Process Stripe refund
if (original.rows[0].metadata.stripe_payment_id) {
await stripe.refunds.create({
payment_intent: original.rows[0].metadata.stripe_payment_id,
amount: Math.round(refundAmount * 100),
reason: 'requested_by_customer',
});
}
// 3. Create reversal transaction
const reversal = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'refund', $3, 'completed', $4)
RETURNING *`,
[
original.rows[0].user_id,
`refund-${transactionId}-${Date.now()}`,
refundAmount,
JSON.stringify({
original_transaction_id: transactionId,
reason
})
]
);
// 4. Reverse account entries
await debitUserAccount(
original.rows[0].user_id,
refundAmount,
reversal.rows[0].id
);
// 5. Mark original transaction as reversed
await db.query(
`UPDATE transactions
SET status = 'reversed',
metadata = metadata || $1
WHERE id = $2`,
[
JSON.stringify({ reversal_transaction_id: reversal.rows[0].id }),
transactionId
]
);
await db.query('COMMIT');
return reversal.rows[0];
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
```
---
### 5.8 Credit System Best Practices
- [ ] Use DECIMAL for monetary values (never FLOAT)
- [ ] Implement idempotency for all financial transactions
- [ ] Use database transactions (BEGIN/COMMIT/ROLLBACK)
- [ ] Lock accounts during balance updates (SELECT FOR UPDATE)
- [ ] Store complete audit trail in transaction_entries
- [ ] Implement double-entry bookkeeping pattern
- [ ] Support multiple credit types (paid, bonus, promotional)
- [ ] Handle credit expiration gracefully
- [ ] Provide refund/reversal capabilities
- [ ] Monitor balance consistency (cached vs calculated)
- [ ] Use meaningful idempotency keys
- [ ] Store rich metadata (app_id, feature_id, user context)
- [ ] Implement spending limits and rate limiting
- [ ] Provide detailed transaction history API
- [ ] Set up alerts for unusual activity
- [ ] Regular reconciliation and auditing
---
## 6. Payment Integration (Stripe)
### 6.1 Stripe Integration Options
#### Option 1: Direct Stripe Integration
- Simple credit purchases
- Single merchant (Mana Core)
- Best for: Centralized credit system
#### Option 2: Stripe Connect
- Marketplace model
- Multiple payees (if apps pay different parties)
- Best for: Revenue sharing, marketplace features
**Recommendation:** Start with Option 1 (direct), upgrade to Connect if needed.
---
### 6.2 Stripe Setup for Credit Purchases
```typescript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20.acacia',
});
// Create payment intent
async function createCreditPurchaseIntent(
userId: string,
amount: number, // Amount in USD
quantity: number // Number of credits
) {
// Generate idempotency key
const idempotencyKey = `purchase-${userId}-${Date.now()}`;
// Create payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses cents
currency: 'usd',
automatic_payment_methods: {
enabled: true, // Enable all payment methods
},
metadata: {
user_id: userId,
credit_quantity: quantity,
idempotency_key: idempotencyKey,
},
}, {
idempotencyKey, // Stripe-level idempotency
});
// Store pending transaction
await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'credit_purchase', $3, 'pending', $4)`,
[
userId,
idempotencyKey,
quantity,
JSON.stringify({
stripe_payment_intent_id: paymentIntent.id,
amount_usd: amount
})
]
);
return {
clientSecret: paymentIntent.client_secret,
idempotencyKey,
};
}
```
---
### 6.3 Webhook Handling
**CRITICAL:** Use webhooks for reliable payment confirmation.
```typescript
import { buffer } from 'micro';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(
buf,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
}
async function handlePaymentSuccess(paymentIntent) {
const { user_id, credit_quantity, idempotency_key } = paymentIntent.metadata;
await db.query('BEGIN');
try {
// 1. Update transaction status
await db.query(
`UPDATE transactions
SET status = 'completed', completed_at = NOW()
WHERE idempotency_key = $1`,
[idempotency_key]
);
// 2. Credit user account
await creditUserAccount(
user_id,
parseFloat(credit_quantity),
idempotency_key
);
// 3. Send confirmation email
await sendPurchaseConfirmationEmail(user_id, credit_quantity);
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
console.error('Error processing payment success:', error);
// Alert team for manual intervention
await alertPaymentProcessingError(paymentIntent.id, error);
}
}
async function handlePaymentFailure(paymentIntent) {
const { idempotency_key } = paymentIntent.metadata;
await db.query(
`UPDATE transactions
SET status = 'failed',
metadata = metadata || $1
WHERE idempotency_key = $2`,
[
JSON.stringify({
failure_reason: paymentIntent.last_payment_error?.message
}),
idempotency_key
]
);
}
```
---
### 6.4 Dynamic Pricing & Credit Packages
```typescript
// Define credit packages
const creditPackages = [
{ id: 'starter', credits: 100, price: 9.99, savings: 0 },
{ id: 'plus', credits: 500, price: 39.99, savings: 20 },
{ id: 'pro', credits: 1000, price: 69.99, savings: 30 },
{ id: 'enterprise', credits: 5000, price: 299.99, savings: 40 },
];
// Create Stripe product and prices (one-time setup)
async function setupStripeProducts() {
for (const pkg of creditPackages) {
const product = await stripe.products.create({
name: `${pkg.credits} Credits`,
description: `Purchase ${pkg.credits} credits${pkg.savings ? ` (${pkg.savings}% savings)` : ''}`,
metadata: {
credit_quantity: pkg.credits,
package_id: pkg.id,
},
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: Math.round(pkg.price * 100),
currency: 'usd',
});
console.log(`Created package ${pkg.id}: ${price.id}`);
}
}
// Purchase specific package
async function purchasePackage(userId: string, packageId: string) {
const pkg = creditPackages.find(p => p.id === packageId);
if (!pkg) throw new Error('Invalid package');
return await createCreditPurchaseIntent(userId, pkg.price, pkg.credits);
}
```
---
### 6.5 Payment Method Management
```typescript
// Save payment method for future use
async function savePaymentMethod(userId: string, paymentMethodId: string) {
// Create or get Stripe customer
let customer = await getStripeCustomer(userId);
if (!customer) {
const user = await getUser(userId);
customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: userId },
});
await saveStripeCustomerId(userId, customer.id);
}
// Attach payment method to customer
await stripe.paymentMethods.attach(paymentMethodId, {
customer: customer.id,
});
// Set as default
await stripe.customers.update(customer.id, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
// Quick purchase with saved payment method
async function quickPurchase(userId: string, packageId: string) {
const customer = await getStripeCustomer(userId);
const pkg = creditPackages.find(p => p.id === packageId);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(pkg.price * 100),
currency: 'usd',
customer: customer.id,
payment_method: customer.invoice_settings.default_payment_method,
off_session: true,
confirm: true,
metadata: {
user_id: userId,
credit_quantity: pkg.credits,
},
});
return paymentIntent;
}
```
---
### 6.6 Subscription Model (Optional)
```typescript
// Monthly credit subscription
async function createCreditSubscription(
userId: string,
monthlyCredits: number,
price: number
) {
const customer = await getStripeCustomer(userId);
// Create subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{
price: await getOrCreateSubscriptionPrice(monthlyCredits, price),
}],
metadata: {
user_id: userId,
monthly_credits: monthlyCredits,
},
});
// Store subscription in database
await db.query(
`INSERT INTO subscriptions (
user_id, stripe_subscription_id, monthly_credits, status
) VALUES ($1, $2, $3, $4)`,
[userId, subscription.id, monthlyCredits, subscription.status]
);
return subscription;
}
// Handle subscription renewal webhook
async function handleSubscriptionRenewal(subscription) {
const { user_id, monthly_credits } = subscription.metadata;
// Grant monthly credits
await grantMonthlyCredits(user_id, parseInt(monthly_credits));
}
```
---
### 6.7 Stripe Best Practices
- [ ] Always verify webhook signatures
- [ ] Use idempotency keys for all operations
- [ ] Store Stripe customer ID in user table
- [ ] Handle all relevant webhook events
- [ ] Implement retry logic for failed webhooks
- [ ] Never trust client-side payment amounts
- [ ] Use metadata extensively for context
- [ ] Test with Stripe test mode thoroughly
- [ ] Implement proper error handling
- [ ] Log all payment-related events
- [ ] Set up Stripe dashboard alerts
- [ ] Monitor for fraudulent activity
- [ ] Provide clear refund policy
- [ ] Support multiple payment methods
- [ ] Keep Stripe SDK updated
---
## 7. Multi-App Authentication Patterns
### 7.1 Architecture Overview
**Centralized Auth Server Pattern:**
```
┌─────────────────────────────────────────────────────────────┐
│ Mana Core Auth Service │
│ - Issues: manaToken, appToken, refreshToken │
│ - Single source of truth for users │
│ - Manages sessions, devices, 2FA │
│ - Credit system integrated │
└────────────────┬────────────────────────────────────────────┘
│ JWT (RS256)
┌────────────┴────────────┬───────────────┬────────────┐
│ │ │ │
┌───▼───────┐ ┌───────▼────┐ ┌────▼─────┐ ┌──▼──────┐
│Maerchen- │ │ Memoro │ │ Picture │ │ Chat │
│ zauber │ │ │ │ │ │ │
└───────────┘ └────────────┘ └──────────┘ └─────────┘
- Validates JWT - Validates JWT - Validates - Validates
- Checks app_id - RLS policies - RLS - RLS
- Uses credits - Uses credits - Credits - Credits
```
---
### 7.2 Token Types in Mana Ecosystem
#### 1. manaToken
- **Purpose:** Universal authentication across all Mana apps
- **Payload:**
```typescript
interface ManaToken {
sub: string; // user_id
iss: 'manacore-auth';
aud: ['manacore', 'maerchenzauber', 'memoro', 'picture', 'chat'];
exp: number; // 15 minutes
iat: number;
role: 'user' | 'admin';
credits: number; // Current credit balance
}
```
#### 2. appToken (Supabase-compatible)
- **Purpose:** App-specific token for Supabase RLS
- **Payload:**
```typescript
interface AppToken {
sub: string; // user_id
iss: 'manacore-auth';
aud: 'maerchenzauber'; // Single app
app_id: 'maerchenzauber';
exp: number;
iat: number;
role: 'authenticated';
// App-specific claims
tenant_id?: string;
}
```
#### 3. refreshToken
- **Purpose:** Long-lived token for renewing access
- **Storage:** Database, single-use with rotation
- **No payload:** Opaque token looked up in database
---
### 7.3 Authentication Flow
```typescript
// 1. User login
async function login(email: string, password: string) {
// Validate credentials
const user = await validateCredentials(email, password);
// Generate token trio
const manaToken = generateManaToken(user);
const refreshToken = generateRefreshToken();
// Store session
await db.query(
`INSERT INTO sessions (
user_id, refresh_token, device_info, ip_address, expires_at
) VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days')`,
[user.id, refreshToken, deviceInfo, ipAddress]
);
return {
manaToken,
refreshToken,
user: {
id: user.id,
email: user.email,
credits: user.credits,
},
};
}
// 2. App-specific token request
async function getAppToken(manaToken: string, appId: string) {
// Verify mana token
const payload = jwt.verify(manaToken, publicKey);
// Check app access
const hasAccess = await checkAppAccess(payload.sub, appId);
if (!hasAccess) throw new Error('No access to app');
// Generate app-specific token
const appToken = jwt.sign(
{
sub: payload.sub,
iss: 'manacore-auth',
aud: appId,
app_id: appId,
role: 'authenticated',
},
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
return appToken;
}
// 3. Token refresh
async function refreshTokens(refreshToken: string) {
// Validate refresh token
const session = await db.query(
'SELECT * FROM sessions WHERE refresh_token = $1 AND expires_at > NOW()',
[refreshToken]
);
if (!session.rows.length) {
throw new Error('Invalid refresh token');
}
// Check for reuse (security)
if (session.rows[0].last_used_at &&
Date.now() - session.rows[0].last_used_at.getTime() < 5000) {
// Token reuse detected - revoke all sessions
await revokeAllUserSessions(session.rows[0].user_id);
throw new Error('Token reuse detected');
}
// Generate new tokens
const user = await getUser(session.rows[0].user_id);
const newManaToken = generateManaToken(user);
const newRefreshToken = generateRefreshToken();
// Update session (token rotation)
await db.query(
`UPDATE sessions
SET refresh_token = $1, last_used_at = NOW()
WHERE id = $2`,
[newRefreshToken, session.rows[0].id]
);
return {
manaToken: newManaToken,
refreshToken: newRefreshToken,
};
}
```
---
### 7.4 Shared Auth Service (@manacore/shared-auth)
```typescript
// packages/shared-auth/src/auth-service.ts
export interface AuthServiceConfig {
authUrl: string; // Mana Core auth API
appId: string; // App identifier
storage: AuthStorage; // Platform-specific storage
}
export class AuthService {
private config: AuthServiceConfig;
private manaToken: string | null = null;
private appToken: string | null = null;
private refreshToken: string | null = null;
constructor(config: AuthServiceConfig) {
this.config = config;
this.loadTokens();
}
// Login
async login(email: string, password: string) {
const response = await fetch(`${this.config.authUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
await this.setTokens(data.manaToken, data.refreshToken);
await this.getAppToken();
return data.user;
}
// Get app-specific token
async getAppToken() {
if (!this.manaToken) throw new Error('Not authenticated');
const response = await fetch(`${this.config.authUrl}/auth/app-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.manaToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ appId: this.config.appId }),
});
const { appToken } = await response.json();
this.appToken = appToken;
await this.config.storage.setItem('appToken', appToken);
return appToken;
}
// Refresh tokens
async refresh() {
if (!this.refreshToken) throw new Error('No refresh token');
const response = await fetch(`${this.config.authUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken }),
});
const data = await response.json();
await this.setTokens(data.manaToken, data.refreshToken);
await this.getAppToken();
return data;
}
// Auto-refresh middleware
async getValidAppToken() {
// Check if app token expired
if (this.appToken && this.isTokenExpired(this.appToken)) {
// Try refreshing
await this.refresh();
}
return this.appToken;
}
private async setTokens(manaToken: string, refreshToken: string) {
this.manaToken = manaToken;
this.refreshToken = refreshToken;
await this.config.storage.setItem('manaToken', manaToken);
await this.config.storage.setItem('refreshToken', refreshToken);
}
private isTokenExpired(token: string): boolean {
const decoded = jwt.decode(token) as any;
return decoded.exp * 1000 < Date.now();
}
private async loadTokens() {
this.manaToken = await this.config.storage.getItem('manaToken');
this.appToken = await this.config.storage.getItem('appToken');
this.refreshToken = await this.config.storage.getItem('refreshToken');
}
}
// Platform-specific storage implementations
export interface AuthStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
// Expo mobile
export class ExpoSecureStorage implements AuthStorage {
async getItem(key: string) {
return await SecureStore.getItemAsync(key);
}
async setItem(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
}
async removeItem(key: string) {
await SecureStore.deleteItemAsync(key);
}
}
// Web (httpOnly cookies)
export class BrowserStorage implements AuthStorage {
// Cookies managed server-side
async getItem(key: string) {
// Read from memory or fetch from /auth/session
return null;
}
async setItem(key: string, value: string) {
// Cookies set by server
}
async removeItem(key: string) {
// Call logout endpoint
}
}
```
---
### 7.5 Usage in Apps
#### Expo Mobile App
```typescript
// apps/memoro/mobile/App.tsx
import { createAuthService } from '@manacore/shared-auth';
import { ExpoSecureStorage } from '@manacore/shared-auth/storage';
const authService = createAuthService({
authUrl: process.env.EXPO_PUBLIC_MIDDLEWARE_API_URL,
appId: 'memoro',
storage: new ExpoSecureStorage(),
});
// Login screen
const handleLogin = async (email: string, password: string) => {
try {
const user = await authService.login(email, password);
console.log('Logged in:', user);
} catch (error) {
console.error('Login failed:', error);
}
};
// API calls with auto-refresh
const fetchMemos = async () => {
const token = await authService.getValidAppToken();
const response = await fetch(`${API_URL}/memos`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response.json();
};
```
#### SvelteKit Web App
```typescript
// apps/memoro/web/src/hooks.server.ts
import { createAuthService } from '@manacore/shared-auth';
import { SvelteKitStorage } from '@manacore/shared-auth/storage';
export async function handle({ event, resolve }) {
const authService = createAuthService({
authUrl: import.meta.env.VITE_AUTH_API_URL,
appId: 'memoro',
storage: new SvelteKitStorage(event.cookies),
});
// Make auth service available in routes
event.locals.auth = authService;
// Get user if authenticated
try {
event.locals.user = await authService.getCurrentUser();
} catch (error) {
event.locals.user = null;
}
return resolve(event);
}
```
#### NestJS Backend
```typescript
// apps/memoro/backend/src/auth/auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
@Injectable()
export class JwtAuthGuard implements CanActivate {
private publicKey = readFileSync('public.key');
async canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) return false;
try {
// Verify JWT
const payload = jwt.verify(token, this.publicKey, {
algorithms: ['RS256'],
issuer: 'manacore-auth',
audience: 'memoro',
});
// Set user context
request.user = payload;
// Set database context for RLS
await this.dataSource.query(
'SET LOCAL app.current_user_id = $1',
[payload.sub]
);
return true;
} catch (error) {
return false;
}
}
}
```
---
### 7.6 Cross-App SSO
Users authenticated in one app can seamlessly access others:
```typescript
// User clicks "Open in Memoro" from Maerchenzauber
// 1. Maerchenzauber has manaToken
const manaToken = await authService.getManaToken();
// 2. Deep link with token
const memoroUrl = `memoro://auth?token=${encodeURIComponent(manaToken)}`;
Linking.openURL(memoroUrl);
// 3. Memoro receives token
export default function App() {
useEffect(() => {
Linking.addEventListener('url', async (event) => {
const url = new URL(event.url);
if (url.pathname === '/auth') {
const token = url.searchParams.get('token');
await authService.setManaToken(token);
await authService.getAppToken(); // Get Memoro-specific token
// User now authenticated in Memoro
}
});
}, []);
}
```
---
### 7.7 Multi-App Patterns Summary
**Benefits of Centralized Auth:**
- Single source of truth for users
- Unified credit system
- Seamless cross-app SSO
- Consistent security policies
- Easier compliance & auditing
- Shared auth logic via @manacore/shared-auth
**Implementation Checklist:**
- [ ] Set up Mana Core auth service (NestJS)
- [ ] Generate RS256 key pair
- [ ] Create @manacore/shared-auth package
- [ ] Implement login/refresh endpoints
- [ ] Build app-token generation endpoint
- [ ] Create session management system
- [ ] Integrate with PostgreSQL RLS
- [ ] Add device tracking
- [ ] Implement 2FA
- [ ] Build admin dashboard for user management
- [ ] Set up monitoring and alerts
---
## 8. Technology Recommendation Matrix
### 8.1 Final Recommendations
| Component | Recommended Technology | Alternative | Rationale |
|-----------|------------------------|-------------|-----------|
| **Auth Library** | **Better Auth** | Auth.js | Modern, comprehensive, TypeScript-first, no vendor lock-in |
| **Database** | **PostgreSQL 16+** | - | Industry standard, RLS support, ACID compliance |
| **ORM** | **Drizzle** | Prisma | Better performance, Better Auth integration, type-safe |
| **JWT Algorithm** | **RS256** | - | Asymmetric keys for microservices |
| **Token Storage (Web)** | **httpOnly cookies** | - | XSS protection |
| **Token Storage (Mobile)** | **Expo SecureStore** | - | Encrypted storage |
| **Payment Gateway** | **Stripe** | - | Best-in-class, comprehensive features |
| **Session Management** | **Database + Redis** | DB-only | Redis for blacklist, DB for sessions |
| **Credit Ledger** | **PostgreSQL** | - | ACID transactions essential |
---
### 8.2 Pros & Cons Summary
#### Better Auth
**Pros:**
- FREE and open-source
- Comprehensive features built-in
- TypeScript-first with auto-generation
- Framework-agnostic
- Perfect for monorepos
- Active development (YC-backed)
**Cons:**
- New (2024) - less proven
- Smaller community
- Documentation still growing
#### PostgreSQL + RLS
**Pros:**
- Battle-tested and reliable
- RLS provides defense in depth
- ACID transactions
- Excellent performance
- Rich ecosystem
**Cons:**
- Requires expertise to configure securely
- RLS policies can be complex
#### Stripe
**Pros:**
- Comprehensive payment methods
- Excellent documentation
- Reliable webhooks
- Global reach (47+ countries)
- Strong fraud prevention
**Cons:**
- 2.9% + $0.30 per transaction
- Pricing can add up at scale
---
## 9. Implementation Roadmap
### Phase 1: Foundation (Weeks 1-2)
- [ ] Set up Better Auth with PostgreSQL
- [ ] Generate RS256 key pair
- [ ] Create basic auth API (login, register, refresh)
- [ ] Implement JWT validation middleware
- [ ] Set up user table with Better Auth schema
### Phase 2: Multi-App Integration (Weeks 3-4)
- [ ] Create @manacore/shared-auth package
- [ ] Implement app-token generation
- [ ] Set up session management
- [ ] Add device tracking
- [ ] Configure RLS policies for each app
### Phase 3: Credit System (Weeks 5-6)
- [ ] Create credit ledger schema
- [ ] Implement double-entry bookkeeping
- [ ] Add idempotency handling
- [ ] Build credit purchase API
- [ ] Create credit usage API
### Phase 4: Payment Integration (Weeks 7-8)
- [ ] Set up Stripe account
- [ ] Implement payment intent creation
- [ ] Build webhook handlers
- [ ] Add payment method management
- [ ] Create credit packages
### Phase 5: Advanced Features (Weeks 9-12)
- [ ] Add 2FA with Better Auth
- [ ] Implement multi-session management
- [ ] Build organization/multi-tenancy support
- [ ] Add OAuth providers
- [ ] Create admin dashboard
### Phase 6: Production Readiness (Weeks 13-14)
- [ ] Security audit
- [ ] Performance testing
- [ ] Set up monitoring and alerts
- [ ] Write comprehensive tests
- [ ] Documentation
- [ ] Deploy to production
---
## 10. Security Checklist
### Authentication
- [ ] Use Better Auth or battle-tested solution
- [ ] Implement 2FA for admin accounts
- [ ] Use SCRAM-SHA-256 for database auth
- [ ] Rate limit authentication endpoints
- [ ] Monitor failed login attempts
- [ ] Implement account lockout after failed attempts
### JWT Security
- [ ] Use RS256 algorithm
- [ ] Set access token expiration to 15-30 minutes
- [ ] Implement refresh token rotation
- [ ] Validate all JWT claims (iss, aud, exp, iat)
- [ ] Store tokens in httpOnly cookies (web) or secure storage (mobile)
- [ ] Transmit only over HTTPS
- [ ] Implement token blacklist for revocation
### Database Security
- [ ] Enable PostgreSQL RLS on all multi-tenant tables
- [ ] Use prepared statements (prevent SQL injection)
- [ ] Apply principle of least privilege to database roles
- [ ] Enable SSL/TLS for database connections
- [ ] Regular database backups with encryption
- [ ] Audit logging enabled
- [ ] Regular security updates
### Payment Security
- [ ] Verify Stripe webhook signatures
- [ ] Use idempotency keys for all transactions
- [ ] Never trust client-side amounts
- [ ] Store sensitive data encrypted
- [ ] PCI compliance measures
- [ ] Fraud detection monitoring
- [ ] Regular reconciliation
### General Security
- [ ] All endpoints behind HTTPS
- [ ] Input validation and sanitization
- [ ] Output encoding to prevent XSS
- [ ] CSRF protection
- [ ] Security headers (CSP, HSTS, etc.)
- [ ] Regular dependency updates
- [ ] Security penetration testing
- [ ] Incident response plan
---
## 11. Monitoring & Observability
### Key Metrics to Track
#### Authentication Metrics
- Login success/failure rates
- Token refresh rates
- Session duration distribution
- Failed authentication attempts by IP
- 2FA adoption rate
- Active sessions per user
#### Payment Metrics
- Credit purchase volume
- Payment success/failure rates
- Refund rates
- Average transaction value
- Revenue by credit package
- Payment method distribution
#### Credit System Metrics
- Credit balance distribution
- Credit consumption rates by app
- Low balance alerts
- Expired credits
- Transaction volume
- Balance consistency checks
#### Security Metrics
- Suspicious login attempts
- Token reuse detection
- Failed authorization attempts
- RLS policy violations (shouldn't happen if configured correctly)
- Rate limit hits
### Alerting Rules
```typescript
// Example alert configurations
const alerts = [
{
name: 'High Failed Login Rate',
condition: 'failed_logins > 100 per 5 minutes',
severity: 'high',
action: 'notify_security_team',
},
{
name: 'Payment Webhook Failure',
condition: 'webhook_failures > 5 per 10 minutes',
severity: 'critical',
action: 'page_on_call',
},
{
name: 'Balance Mismatch Detected',
condition: 'balance_mismatch_count > 0',
severity: 'critical',
action: 'notify_finance_team',
},
{
name: 'Token Reuse Detected',
condition: 'token_reuse_count > 0',
severity: 'critical',
action: 'revoke_sessions_and_alert',
},
];
```
---
## 12. Additional Resources
### Documentation
- [Better Auth Docs](https://www.better-auth.com/docs)
- [PostgreSQL RLS Guide](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Stripe API Reference](https://docs.stripe.com/api)
- [JWT Best Practices](https://curity.io/resources/learn/jwt-best-practices/)
- [OAuth 2.0 RFC](https://datatracker.ietf.org/doc/html/rfc6749)
### Libraries & Tools
- [Better Auth](https://github.com/better-auth/better-auth)
- [Drizzle ORM](https://orm.drizzle.team/)
- [jose (JWT library)](https://github.com/panva/jose)
- [Stripe Node SDK](https://github.com/stripe/stripe-node)
### Community
- [Better Auth Discord](https://discord.gg/better-auth)
- [PostgreSQL Slack](https://postgres-slack.herokuapp.com/)
- [Stripe Developers](https://support.stripe.com/developers)
---
## Conclusion
This comprehensive research provides a solid foundation for implementing a secure, scalable, and user-friendly central authentication system for the Mana Universe monorepo.
**Key Takeaways:**
1. **Better Auth** emerges as the best choice for modern TypeScript monorepos, offering comprehensive features, excellent developer experience, and zero vendor lock-in.
2. **PostgreSQL with RLS** provides robust multi-tenancy with defense-in-depth security, essential for a multi-app ecosystem.
3. **Double-entry ledger pattern** with idempotency ensures financial accuracy and auditability for the credit system.
4. **JWT with RS256** and proper token management (short expiration, rotation) provides secure authentication across multiple apps.
5. **Stripe integration** offers reliable payment processing with comprehensive features and global reach.
6. **Centralized auth service** with app-specific tokens enables seamless SSO across the Mana ecosystem while maintaining app isolation.
The recommended architecture balances security, performance, developer experience, and cost-effectiveness, positioning the Mana Universe for scalable growth.
---
**End of Report**
*For questions or clarifications, consult the Queen agent for aggregation with other research streams.*