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

74 KiB

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

  • 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

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

# 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

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

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

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

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

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

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

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

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

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

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

// VULNERABLE TO XSS ATTACKS
localStorage.setItem('token', accessToken); // DON'T DO THIS

Web Applications

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

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

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

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

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

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

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

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

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

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

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

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

Libraries & Tools

Community


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.