Skip to content

API Protection Architecture Documentation

Version: 2.0 Last Updated: January 2026 Status: Implemented - Service Auth + Vercel WAF Rate Limiting


Table of Contents

  1. Executive Summary
  2. Architecture Overview
  3. Role & Permission Design
  4. Rate Limiting Architecture
  5. Paywall System Design
  6. Middleware Architecture
  7. Database Schema Design
  8. Stripe Integration Design
  9. Module Structure
  10. Security Considerations
  11. API Response Patterns
  12. Configuration Reference
  13. Design Decisions

1. Executive Summary

Purpose

This document defines the architecture for implementing comprehensive API protection across the micro-learning platform. The protection system spans two API layers and provides:

  • Tiered access control based on user subscription status
  • Rate limiting with tier-based quotas
  • Paywall protection for premium content
  • Audit logging for security monitoring

Scope

Component Technology Protection Responsibilities
Experience APIs Next.js on Vercel Primary enforcement, BFF orchestration
Domain APIs Supabase Edge Functions (Deno/Hono) Defense in depth, RLS integration
Database Supabase PostgreSQL RLS policies, subscription storage
Payments Stripe Subscription management

Key Design Principles

  1. Defense in Depth: Enforce at both Experience and Domain layers
  2. Fail Secure: Default to deny; require explicit permissions
  3. Graceful Degradation: Anonymous users get preview access, not blocked
  4. Performance First: Edge-based rate limiting, JWT-based roles (no DB lookup)
  5. Pluggable Design: Protection module works independently of business logic
  6. Service Authentication: Domain APIs only accessible via Experience layer (X-Service-Auth)

2. Architecture Overview

2.1 System Context Diagram

┌─────────────────────────────────────────────────────────────────────────────────┐
│                              CLIENT APPLICATIONS                                 │
│           Web Browser    │    iOS App (Future)    │    Android App (Future)     │
└─────────────────────────────────────────────────────────────────────────────────┘
                                        │ HTTPS + JWT
┌─────────────────────────────────────────────────────────────────────────────────┐
│                        EXPERIENCE LAYER (Vercel BFF)                             │
│                                                                                  │
│  ┌────────────────────────────────────────────────────────────────────────────┐ │
│  │                        Vercel WAF / Edge Config                             │ │
│  │                                                                             │ │
│  │  • IP-based rate limiting at edge (configured in Vercel Dashboard)         │ │
│  │  • DDoS protection                                                          │ │
│  │  • Bot detection                                                            │ │
│  └────────────────────────────────────────────────────────────────────────────┘ │
│                                        │                                         │
│  ┌────────────────────────────────────────────────────────────────────────────┐ │
│  │                     Next.js Edge Middleware                                 │ │
│  │                                                                             │ │
│  │  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐   ┌─────────────┐ │ │
│  │  │    Auth      │──▶│    Tier      │──▶│  Permission  │──▶│   Paywall   │ │ │
│  │  │  Extractor   │   │  Resolver    │   │   Checker    │   │   Checker   │ │ │
│  │  │  (JWT Parse) │   │ (Free/Pro/   │   │   (RBAC)     │   │  (Feature   │ │ │
│  │  │              │   │  Premium)    │   │              │   │   Gates)    │ │ │
│  │  └──────────────┘   └──────────────┘   └──────────────┘   └─────────────┘ │ │
│  │         │                                                         │        │ │
│  │         │◀────────────────── Headers ────────────────────────────▶│        │ │
│  │         │      X-Service-Auth + User Context Propagated           │        │ │
│  └─────────┼─────────────────────────────────────────────────────────┼────────┘ │
│            │                                                         │          │
│            ▼                                                         ▼          │
│  ┌─────────────────────────────────────────────────────────────────────────────┐│
│  │                        API Route Handlers                                   ││
│  │                                                                             ││
│  │  /api/discovery/*  │  /api/domains/*  │  /api/me/*  │  /api/search/*      ││
│  └─────────────────────────────────────────────────────────────────────────────┘│
│                                        │                                         │
│                           Domain API Client (HTTP)                              │
│                                        │                                         │
└────────────────────────────────────────┼────────────────────────────────────────┘
                                         │ HTTPS + JWT + X-Service-Auth
┌─────────────────────────────────────────────────────────────────────────────────┐
│                         DOMAIN LAYER (Supabase Edge)                             │
│                                                                                  │
│  ┌────────────────────────────────────────────────────────────────────────────┐ │
│  │                         Hono Middleware Stack                               │ │
│  │                                                                             │ │
│  │  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐   ┌─────────────┐ │ │
│  │  │   Service    │──▶│    Auth      │──▶│    User      │──▶│ Permission  │ │ │
│  │  │    Auth      │   │   Handler    │   │   Context    │   │   Guard     │ │ │
│  │  │ (X-Service-  │   │ (JWT Verify) │   │  Extractor   │   │   (RBAC)    │ │ │
│  │  │   Auth)      │   │              │   │              │   │             │ │ │
│  │  └──────────────┘   └──────────────┘   └──────────────┘   └─────────────┘ │ │
│  │         │                                                                   │ │
│  │         │  Rejects requests without valid X-Service-Auth header            │ │
│  │         │  (Direct access blocked - only Experience layer allowed)          │ │
│  └─────────┼──────────────────────────────────────────────────────────────────┘ │
│                                        │                                         │
│                              Route Handlers                                      │
│                                        │                                         │
│                                        ▼                                         │
│  ┌─────────────────────────────────────────────────────────────────────────────┐│
│  │                     Supabase PostgreSQL + RLS                               ││
│  │                                                                             ││
│  │  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌──────────┐ ││
│  │  │  domains  │  │  trails   │  │  sparks   │  │ journeys  │  │subscrip- │ ││
│  │  │   (RLS)   │  │   (RLS)   │  │   (RLS)   │  │   (RLS)   │  │  tions   │ ││
│  │  └───────────┘  └───────────┘  └───────────┘  └───────────┘  └──────────┘ ││
│  └─────────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────────┘

2.2 Protection Flow Sequence

Client                Experience Layer           Domain Layer           Database
  │                         │                         │                     │
  │──── Request + JWT ─────▶│                         │                     │
  │                         │                         │                     │
  │                   ┌─────┴─────┐                   │                     │
  │                   │ Vercel WAF│                   │                     │
  │                   │Rate Limit │                   │                     │
  │                   └─────┬─────┘                   │                     │
  │                         │                         │                     │
  │                   ┌─────┴─────┐                   │                     │
  │                   │  Decode   │                   │                     │
  │                   │   JWT     │                   │                     │
  │                   └─────┬─────┘                   │                     │
  │                         │                         │                     │
  │                   ┌─────┴─────┐                   │                     │
  │                   │  Resolve  │                   │                     │
  │                   │   Role    │                   │                     │
  │                   └─────┬─────┘                   │                     │
  │                         │                         │                     │
  │                   ┌─────┴─────┐                   │                     │
  │                   │  Check    │                   │                     │
  │                   │Permission │                   │                     │
  │                   └─────┬─────┘                   │                     │
  │                         │                         │                     │
  │                         │── Request + X-Service-Auth + JWT ────▶│       │
  │                         │                         │                     │
  │                         │                   ┌─────┴─────┐               │
  │                         │                   │  Verify   │               │
  │                         │                   │ X-Service │               │
  │                         │                   │   Auth    │               │
  │                         │                   └─────┬─────┘               │
  │                         │                         │                     │
  │                         │                   ┌─────┴─────┐               │
  │                         │                   │  Verify   │               │
  │                         │                   │   JWT     │               │
  │                         │                   └─────┬─────┘               │
  │                         │                         │                     │
  │                         │                         │── Query + RLS ─────▶│
  │                         │                         │                     │
  │                         │                         │◀──── Results ───────│
  │                         │                         │                     │
  │                         │◀─────── Response ───────│                     │
  │                         │                         │                     │
  │                   ┌─────┴─────┐                   │                     │
  │                   │  Apply    │                   │                     │
  │                   │ Paywall   │                   │                     │
  │                   └─────┬─────┘                   │                     │
  │                         │                         │                     │
  │◀─── Response + Headers ─│                         │                     │
  │                         │                         │                     │

2.3 Layer Responsibilities Matrix

Responsibility Experience Layer Domain Layer Database
Rate Limiting Vercel WAF/Edge - (removed) -
Service Auth Adds X-Service-Auth header Verifies X-Service-Auth -
Authentication JWT parsing JWT validation -
Role Resolution From JWT claims Verify claims Store subscription
Permission Check Route-level Handler-level -
Paywall Enforcement Content transformation Flag content access_tier column
Audit Logging Structured stdout Structured stdout -
RLS Enforcement - - Row-level policies

3. Role & Permission Design

3.1 User Role Hierarchy

                              ┌──────────┐
                              │  Admin   │  ← Full platform access
                              └────┬─────┘
                              ┌────┴─────┐
                              │ Service  │  ← API-to-API access
                              └────┬─────┘
                              ┌────┴─────┐
                              │ Premium  │  ← Highest paid tier
                              └────┬─────┘
                              ┌────┴─────┐
                              │   Pro    │  ← Standard paid tier
                              └────┬─────┘
                              ┌────┴─────┐
                              │   Free   │  ← Authenticated, no subscription
                              └────┬─────┘
                              ┌────┴─────┐
                              │Anonymous │  ← Unauthenticated
                              └──────────┘

3.2 Role Definitions

enum UserRole {
  ANONYMOUS = 'anonymous',   // No authentication
  FREE = 'free',             // Authenticated, no subscription
  PRO = 'pro',               // Monthly/yearly subscription
  PREMIUM = 'premium',       // Higher tier subscription
  ADMIN = 'admin',           // Platform administrators
  SERVICE = 'service',       // Service accounts (internal)
}

3.3 Permission Definitions

enum Permission {
  // Content Access
  READ_PUBLIC_CONTENT = 'read:public_content',
  READ_PREVIEW_CONTENT = 'read:preview_content',
  READ_FULL_CONTENT = 'read:full_content',
  READ_PREMIUM_CONTENT = 'read:premium_content',

  // Learning Features
  TRACK_PROGRESS = 'track:progress',
  CREATE_JOURNEY = 'create:journey',
  ACCESS_SPACED_REPETITION = 'access:spaced_repetition',
  ACCESS_ADVANCED_ANALYTICS = 'access:advanced_analytics',

  // Search Features
  BASIC_SEARCH = 'search:basic',
  ADVANCED_SEARCH = 'search:advanced',
  UNLIMITED_SEARCH = 'search:unlimited',

  // Admin Features
  MANAGE_CONTENT = 'manage:content',
  MANAGE_USERS = 'manage:users',
  VIEW_ANALYTICS = 'view:analytics',
  BYPASS_RATE_LIMITS = 'bypass:rate_limits',
}

3.4 Role-Permission Matrix

Permission Anonymous Free Pro Premium Admin Service
read:public_content
read:preview_content
read:full_content -
read:premium_content - -
search:basic
search:advanced - -
search:unlimited - - -
track:progress - -
create:journey - -
access:spaced_repetition - - -
access:advanced_analytics - - - -
manage:content - - - - -
manage:users - - - - -
bypass:rate_limits - - - -

3.5 UserContext Type

interface UserContext {
  id: string | null;              // User UUID or null for anonymous
  role: UserRole;                 // Resolved role
  permissions: Permission[];       // Granted permissions
  subscriptionActive: boolean;     // Whether subscription is active
  subscriptionPlan: string | null; // 'pro', 'premium', or null
}

3.6 JWT Claims Structure

Roles are stored in JWT custom claims via Supabase Auth Hook:

{
  "aud": "authenticated",
  "exp": 1736496000,
  "sub": "user-uuid-here",
  "email": "user@example.com",
  "role": "authenticated",
  "user_role": "pro",
  "subscription_active": true,
  "subscription_plan": "pro",
  "app_metadata": {
    "provider": "email"
  },
  "user_metadata": {
    "display_name": "John Doe"
  }
}

4. Rate Limiting Architecture

Architecture Change (v2.0): In-memory rate limiting has been removed from both Experience and Domain layers. Rate limiting is now handled by Vercel WAF/Edge at the infrastructure level. This change was made because serverless functions (Supabase Edge Functions) are stateless and cannot reliably maintain in-memory rate limit state across cold starts and multiple instances.

4.1 Current Implementation

Rate Limiting Location: Vercel WAF/Edge (configured in Vercel Dashboard)

Why In-Memory Was Removed: - Supabase Edge Functions are serverless and stateless - Cold starts reset in-memory state - Multiple instances don't share memory - Rate limits were unreliable and easily bypassed

Current Flow:

Client Request → Vercel Edge (WAF Rate Limiting) → Next.js Middleware → Domain API

4.2 Algorithm Reference: Sliding Window Counter (Historical)

The following describes the sliding window counter algorithm that was previously used. This is kept for reference in case persistent rate limiting (e.g., Upstash Redis) is added in the future.

Time ─────────────────────────────────────────────────────▶

Window 1 (Fixed)          Window 2 (Fixed)
├────────────────────────┼────────────────────────┤
│    Requests: 30        │    Requests: 25        │
│                        │                        │
│         ┌──────────────┼──────────┐             │
│         │  Sliding Window (60s)   │             │
│         │  Weighted: 0.3×30 + 25  │             │
│         │  = 34 requests          │             │
│         └──────────────┼──────────┘             │

Formula:

effective_count = (previous_window_count × overlap_ratio) + current_window_count

4.2 Rate Limit Tiers

Category Anonymous Free Pro Premium Admin
Default 30/min 100/min 300/min 600/min
Search 10/min 30/min 100/min 200/min
Autocomplete 30/min 60/min 120/min 300/min
Content 20/min 60/min 200/min 500/min
Events - 60/min 120/min 300/min

4.3 Rate Limiter Configuration

interface RateLimitConfig {
  windowMs: number;      // Window size in milliseconds (default: 60000)
  maxRequests: number;   // Max requests per window
  keyPrefix: string;     // Cache key prefix for isolation
}

const RATE_LIMITS: Record<UserRole, Record<string, RateLimitConfig>> = {
  [UserRole.ANONYMOUS]: {
    'default': { windowMs: 60_000, maxRequests: 30, keyPrefix: 'rl:anon' },
    'search': { windowMs: 60_000, maxRequests: 10, keyPrefix: 'rl:anon:search' },
    'autocomplete': { windowMs: 60_000, maxRequests: 30, keyPrefix: 'rl:anon:ac' },
    'content': { windowMs: 60_000, maxRequests: 20, keyPrefix: 'rl:anon:content' },
  },
  // ... other roles
};

4.4 Rate Limiter Implementation

class MemoryRateLimiter {
  private store: Map<string, { count: number; windowStart: number }>;
  private maxEntries: number = 10000;

  check(identifier: string, config: RateLimitConfig): RateLimitResult {
    const key = `${config.keyPrefix}:${identifier}`;
    const now = Date.now();

    let entry = this.store.get(key);

    // Reset if window expired
    if (!entry || now - entry.windowStart >= config.windowMs) {
      entry = { count: 0, windowStart: now };
    }

    entry.count++;
    this.store.set(key, entry);

    // Periodic cleanup of old entries
    if (this.store.size > this.maxEntries) {
      this.cleanup(now - config.windowMs * 2);
    }

    return {
      allowed: entry.count <= config.maxRequests,
      remaining: Math.max(0, config.maxRequests - entry.count),
      resetAt: Math.ceil((entry.windowStart + config.windowMs) / 1000),
      retryAfter: entry.count > config.maxRequests
        ? Math.ceil((entry.windowStart + config.windowMs - now) / 1000)
        : undefined,
    };
  }
}

4.5 Client Identifier Resolution

function getClientIdentifier(request: Request, userContext: UserContext): string {
  // Priority order:
  // 1. Authenticated user ID (most reliable)
  if (userContext.id) {
    return `user:${userContext.id}`;
  }

  // 2. Client IP (for anonymous)
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
    || request.headers.get('cf-connecting-ip')
    || request.headers.get('x-real-ip')
    || 'unknown';

  return `ip:${ip}`;
}

4.6 Anonymous Rate Limiting (No API Token Required)

For public Browse APIs that allow anonymous access (verify_jwt = false), rate limiting is implemented without requiring any API tokens or JWT authentication. This section explains how.

4.6.1 How It Works

┌─────────────────────────────────────────────────────────────────────────────────┐
│                    Anonymous Rate Limiting Flow                                   │
└─────────────────────────────────────────────────────────────────────────────────┘

   Client                     CDN/Proxy                 Edge Function
     │                           │                           │
     │──── HTTP Request ────────▶│                           │
     │                           │                           │
     │                    Add forwarding headers:            │
     │                    • X-Forwarded-For: client IP       │
     │                    • CF-Connecting-IP (Cloudflare)    │
     │                    • X-Real-IP (nginx)                │
     │                           │                           │
     │                           │── Request + Headers ─────▶│
     │                           │                           │
     │                           │                    ┌──────┴──────┐
     │                           │                    │  Extract IP  │
     │                           │                    │  from headers│
     │                           │                    └──────┬──────┘
     │                           │                           │
     │                           │                    ┌──────┴──────┐
     │                           │                    │ Rate limit   │
     │                           │                    │ check by IP  │
     │                           │                    └──────┬──────┘
     │                           │                           │
     │                           │◀── Response + Headers ────│
     │                           │    X-RateLimit-*          │
     │                           │                           │
     │◀── Response ─────────────│                           │

4.6.2 IP Address Extraction

The edge function extracts the client IP from HTTP headers set by CDN/proxy infrastructure:

function extractClientIP(getHeader: (name: string) => string | undefined): string {
  // Priority order - most reliable to least reliable
  const ipSources = [
    // 1. Cloudflare's connecting IP (most reliable when using Cloudflare)
    getHeader('cf-connecting-ip'),

    // 2. Standard forwarding header (first IP is original client)
    getHeader('x-forwarded-for')?.split(',')[0]?.trim(),

    // 3. nginx real IP header
    getHeader('x-real-ip'),

    // 4. Supabase-specific headers
    getHeader('x-client-ip'),
  ];

  // Return first non-empty value, or 'unknown' as fallback
  for (const ip of ipSources) {
    if (ip && ip !== 'unknown') {
      return ip;
    }
  }

  return 'unknown';
}

4.6.3 Serverless Rate Limiting Challenge

Important: Supabase Edge Functions are serverless and stateless. They can: - Cold start on each request - Scale horizontally across multiple isolated instances - Not share memory between invocations - Be terminated at any time

In-memory rate limiting alone is NOT sufficient for serverless functions.

4.6.4 Multi-Layer Protection Strategy

Given the stateless nature of edge functions, protection is implemented through multiple complementary layers:

┌─────────────────────────────────────────────────────────────────────────────────┐
│                    Multi-Layer Protection for Serverless                          │
└─────────────────────────────────────────────────────────────────────────────────┘

   Layer 1: CDN/Edge (Cloudflare/Supabase Gateway)
   ┌─────────────────────────────────────────────────────────────────────────────┐
   │  • DDoS protection (automatic)                                               │
   │  • Geographic rate limiting                                                  │
   │  • Bot detection                                                             │
   │  • Request throttling at edge                                                │
   │  • WAF rules for suspicious patterns                                         │
   └─────────────────────────────────────────────────────────────────────────────┘
   Layer 2: Supabase Gateway (verify_jwt)
   ┌─────────────────────────────────────────────────────────────────────────────┐
   │  • JWT verification for protected functions                                  │
   │  • Reject unauthenticated requests to user/snapshots functions               │
   │  • Pass-through for public functions (browse, search, etc.)                  │
   └─────────────────────────────────────────────────────────────────────────────┘
   Layer 3: Edge Function (Application-Level)
   ┌─────────────────────────────────────────────────────────────────────────────┐
   │  • Best-effort in-memory rate limiting (within worker lifetime)              │
   │  • User context extraction from JWT                                          │
   │  • Permission checks                                                         │
   │  • Audit logging                                                             │
   │  • Rate limit headers (informational)                                        │
   └─────────────────────────────────────────────────────────────────────────────┘
   Layer 4: Database (PostgreSQL RLS)
   ┌─────────────────────────────────────────────────────────────────────────────┐
   │  • Row-level security policies                                               │
   │  • Data access control based on user context                                 │
   │  • Ultimate defense against unauthorized data access                         │
   └─────────────────────────────────────────────────────────────────────────────┘

4.6.5 Rate Limiting Implementation Options

Option Pros Cons Recommended For
Supabase Edge Gateway Built-in, no code needed Limited customization Basic protection
Upstash Redis Persistent, shared state External dependency, latency Production at scale
Cloudflare Rate Limiting Edge-level, very fast Requires Cloudflare plan High-traffic APIs
In-Memory (Best-Effort) Zero latency, no dependencies Resets on cold start Supplementary only
Database Counter Persistent, accurate Higher latency, DB load Critical endpoints

For robust rate limiting in serverless environments, use Upstash Redis or similar:

// Production rate limiting with Upstash Redis
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: Deno.env.get("UPSTASH_REDIS_REST_URL")!,
  token: Deno.env.get("UPSTASH_REDIS_REST_TOKEN")!,
});

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "60 s"), // 20 requests per minute
  analytics: true,
  prefix: "rl:browse",
});

// In middleware
const identifier = clientIP; // or userId for authenticated
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);

if (!success) {
  return c.json({ error: { code: "RATE_LIMITED" } }, 429);
}

Why Upstash Redis? - Serverless-native: HTTP-based, no connection pooling issues - Global replication: Low latency worldwide - Persistent: Survives cold starts and scaling - Cost-effective: Pay-per-request pricing

4.6.7 Current Implementation Status

Layer Status Notes
CDN/DDoS Protection ✅ Active Supabase provides automatic DDoS protection
Vercel WAF Rate Limiting ✅ Active Configure in Vercel Dashboard
Service Authentication ✅ Active X-Service-Auth header required for Domain APIs
Gateway JWT Verification ✅ Active verify_jwt in config.toml
In-Memory Rate Limiting ❌ Removed Was unreliable in serverless
Upstash/Redis Rate Limiting 🔲 Optional Can add for more granular control
Database RLS ✅ Active Row-level security enforced

4.6.8 Best-Effort In-Memory Rate Limiter

The current in-memory implementation provides supplementary protection within worker lifetime:

// Singleton rate limiter - persists while worker is warm
const rateLimiter = new MemoryRateLimiter();

// Store structure: Map<identifier, { count, windowStart }>
// Example entries (while worker is warm):
// "rl:anon:content:ip:203.0.113.45" → { count: 15, windowStart: 1704067140000 }

Behavior: - Warm Worker: Rate limits enforced within that instance - Cold Start: Rate limit state resets (new Map) - Multiple Instances: Each has independent counters - Result: Limits are "soft" - may allow more requests than configured during scaling

When This Works: - Sustained traffic keeps workers warm - Single-instance deployments - As a second line of defense with external rate limiting

When This Fails: - Bursty traffic with cold starts - Distributed attacks - High-scale multi-instance deployments

4.6.9 Rate Limit Identifier Format

For anonymous requests, the identifier combines the key prefix with the client IP:

Format: {keyPrefix}:{identifier_type}:{value}

Examples:
- rl:anon:content:ip:203.0.113.45    (anonymous, content endpoint, specific IP)
- rl:anon:search:ip:198.51.100.23    (anonymous, search endpoint, specific IP)
- rl:free:content:user:abc123-uuid   (authenticated free user)

4.6.10 Complete Anonymous Request Flow

// Inside protection middleware for anonymous-allowed routes

export function protectionMiddleware(config: ProtectionConfig) {
  return async (c: Context, next: Function) => {
    // 1. Check for JWT (optional for public routes)
    const authHeader = c.req.header('Authorization');
    let userContext: UserContext;

    if (authHeader?.startsWith('Bearer ')) {
      // Authenticated request - extract user from JWT
      userContext = resolveUserContextFromHeader(authHeader);
    } else {
      // Anonymous request - no JWT required
      userContext = createUserContext(UserRole.ANONYMOUS);
    }

    // 2. Extract client identifier (IP for anonymous, user ID for authenticated)
    const clientIP = extractClientIP((name) => c.req.header(name));
    const identifier = createRateLimitIdentifier(userContext.id, clientIP);
    // For anonymous: "ip:203.0.113.45"
    // For authenticated: "user:abc123-uuid"

    // 3. Get rate limit config based on role and endpoint category
    const rateLimitConfig = getRateLimitConfig(
      userContext.role,        // UserRole.ANONYMOUS
      config.rateLimitCategory // e.g., "content"
    );
    // Returns: { windowMs: 60000, maxRequests: 20, keyPrefix: "rl:anon:content" }

    // 4. Check rate limit (in-memory)
    const result = rateLimiter.check(identifier, rateLimitConfig);
    // result: { allowed: true, remaining: 19, resetAt: 1704067200 }

    // 5. Set rate limit headers on response
    c.header('X-RateLimit-Limit', String(rateLimitConfig.maxRequests));
    c.header('X-RateLimit-Remaining', String(result.remaining));
    c.header('X-RateLimit-Reset', String(result.resetAt));

    // 6. Block if rate limited
    if (!result.allowed) {
      c.header('Retry-After', String(result.retryAfter));
      return c.json({
        error: {
          code: 'RATE_LIMITED',
          message: 'Too many requests',
          retryAfter: result.retryAfter
        }
      }, 429);
    }

    // 7. Continue to route handler
    c.set('userContext', userContext);
    await next();
  };
}

4.6.11 Why No API Tokens Are Needed

Concern How It's Addressed
Identification Client IP address from proxy headers uniquely identifies the source
Abuse Prevention Rate limits per IP prevent individual sources from overwhelming the API
No Signup Friction Users can immediately access public content without registration
SEO Friendly Search engine crawlers can index content without authentication
Simple Integration Third-party consumers can use simple HTTP requests

4.6.12 Limitations and Mitigations

Limitation Impact Mitigation
Shared IPs (NAT/VPN) Multiple users behind same IP share limit Higher anonymous limits (20-30/min) accommodate shared IPs
IP Spoofing Attacker could forge X-Forwarded-For CDN overwrites with actual client IP; trust CDN headers only
Distributed Attacks Attackers using many IPs WAF rules + DDoS protection at CDN layer
Serverless Cold Starts In-memory state lost Use Upstash Redis for persistent rate limiting
Multiple Worker Instances Each has independent counters External rate limiter (Redis) for shared state
Worker Restarts/Scaling Rate limit state lost Redis-based rate limiting survives restarts

4.6.13 Upgrading to Authenticated Access

Anonymous users hitting rate limits can authenticate to get higher limits:

┌─────────────────────────────────────────────────────────────────────────────────┐
│                    Rate Limit Upgrade Path                                       │
└─────────────────────────────────────────────────────────────────────────────────┘

   Anonymous (IP-based)          Authenticated (User ID-based)
   ────────────────────          ─────────────────────────────

   Content: 20 req/min    ───▶   Free:    60 req/min
   Search:  10 req/min    ───▶   Free:    30 req/min
   Default: 30 req/min    ───▶   Free:   100 req/min

                          ───▶   Pro:    200-300 req/min
                          ───▶   Premium: 500-600 req/min

Benefits of Authentication:

  1. Higher Limits: 3-25x higher rate limits
  2. User-Based Tracking: Limits follow user, not IP (works across devices)
  3. Personalization: Track progress, save bookmarks
  4. Premium Content: Access gated content based on subscription

4.7 Response Headers

All responses include rate limit headers:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1736496060
Retry-After: 45  (only on 429 responses)

5. Paywall System Design

5.1 Content Tier Model

┌────────────────────────────────────────────────────────────────────────┐
│                           Content Tiers                                 │
├────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐     │
│  │      FREE        │  │       PRO        │  │     PREMIUM      │     │
│  │                  │  │                  │  │                  │     │
│  │  • Basic domains │  │  • Pro domains   │  │  • Premium       │     │
│  │  • Intro trails  │  │  • Advanced      │  │    exclusive     │     │
│  │  • Public sparks │  │    trails        │  │  • Early access  │     │
│  │                  │  │  • All sparks    │  │  • All content   │     │
│  │                  │  │                  │  │                  │     │
│  │  Accessible by:  │  │  Accessible by:  │  │  Accessible by:  │     │
│  │  Free, Pro,      │  │  Pro, Premium,   │  │  Premium, Admin  │     │
│  │  Premium, Admin  │  │  Admin           │  │                  │     │
│  └──────────────────┘  └──────────────────┘  └──────────────────┘     │
│                                                                         │
└────────────────────────────────────────────────────────────────────────┘

5.2 Access Decision Flow

                              ┌─────────────┐
                              │   Content   │
                              │  Requested  │
                              └──────┬──────┘
                              ┌──────▼──────┐
                              │   Get       │
                              │ access_tier │
                              │ from content│
                              └──────┬──────┘
                              ┌──────▼──────┐
                              │   Get       │
                              │ user role   │
                              └──────┬──────┘
                      ┌──────────────┼──────────────┐
                      │              │              │
               ┌──────▼──────┐┌─────▼─────┐┌──────▼──────┐
               │ tier=free   ││ tier=pro  ││tier=premium │
               └──────┬──────┘└─────┬─────┘└──────┬──────┘
                      │             │             │
      ┌───────────────┼─────────────┼─────────────┼───────────────┐
      │               │             │             │               │
      ▼               ▼             ▼             ▼               ▼
 ┌─────────┐    ┌─────────┐   ┌─────────┐   ┌─────────┐    ┌─────────┐
 │Anonymous│    │  Free   │   │   Pro   │   │ Premium │    │  Admin  │
 └────┬────┘    └────┬────┘   └────┬────┘   └────┬────┘    └────┬────┘
      │              │             │             │               │
      ▼              ▼             ▼             ▼               ▼
 ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐    ┌─────────┐
 │ Preview │   │  Full   │   │  Full   │   │  Full   │    │  Full   │
 │  Only   │   │ Access  │   │ Access  │   │ Access  │    │ Access  │
 └─────────┘   └─────────┘   └─────────┘   └─────────┘    └─────────┘

5.3 Paywall Logic

interface PaywallResult {
  accessible: boolean;     // Can user see content at all
  previewOnly: boolean;    // Limited to preview
  requiredTier: string;    // Tier needed for full access
  upgradeMessage?: string; // Suggested upgrade action
}

function checkPaywall(
  content: { access_tier: 'free' | 'pro' | 'premium' },
  userRole: UserRole,
  permissions: Permission[]
): PaywallResult {
  const tierLevel = { free: 0, pro: 1, premium: 2 };
  const roleLevel = {
    anonymous: -1, free: 0, pro: 1, premium: 2, admin: 3, service: 3
  };

  const contentLevel = tierLevel[content.access_tier];
  const userLevel = roleLevel[userRole];

  // Full access
  if (userLevel >= contentLevel || permissions.includes(Permission.READ_PREMIUM_CONTENT)) {
    return { accessible: true, previewOnly: false, requiredTier: content.access_tier };
  }

  // Preview access for authenticated users or anonymous with preview permission
  if (permissions.includes(Permission.READ_PREVIEW_CONTENT)) {
    return {
      accessible: true,
      previewOnly: true,
      requiredTier: content.access_tier,
      upgradeMessage: `Upgrade to ${content.access_tier} to access full content`,
    };
  }

  // No access
  return {
    accessible: false,
    previewOnly: false,
    requiredTier: content.access_tier,
  };
}

5.4 Content Transformation

For preview mode, content is truncated:

function applyPaywall(content: PaywalledContent, result: PaywallResult): PaywalledContent {
  if (!result.previewOnly) return content;

  const preview = { ...content };

  // Truncate markdown to 30%
  if (preview.content_md) {
    const lines = preview.content_md.split('\n');
    const previewLines = Math.ceil(lines.length * 0.3);
    preview.content_md = lines.slice(0, previewLines).join('\n')
      + '\n\n---\n\n*[Content preview - upgrade to continue reading]*';
  }

  // Add paywall metadata
  preview._paywall = {
    previewOnly: true,
    requiredTier: result.requiredTier,
    upgradeMessage: result.upgradeMessage,
  };

  return preview;
}

6. Middleware Architecture

6.1 Domain API Middleware (Hono)

// middleware/protection.ts

interface ProtectionConfig {
  requiredPermissions?: Permission[];
  rateLimitCategory?: string;
  skipRateLimit?: boolean;
  allowAnonymous?: boolean;
}

function protectionMiddleware(config: ProtectionConfig = {}): MiddlewareHandler {
  return async (c, next) => {
    // 1. Extract user context from JWT
    const userContext = extractUserContext(c.req.header('Authorization'));

    // 2. Check authentication requirement
    if (!config.allowAnonymous && userContext.role === UserRole.ANONYMOUS) {
      return c.json({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' }}, 401);
    }

    // 3. Check permissions
    for (const permission of config.requiredPermissions || []) {
      if (!userContext.permissions.includes(permission)) {
        return c.json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }}, 403);
      }
    }

    // 4. Apply rate limiting
    if (!config.skipRateLimit && !userContext.permissions.includes(Permission.BYPASS_RATE_LIMITS)) {
      const result = rateLimiter.check(
        getClientIdentifier(c.req, userContext),
        RATE_LIMITS[userContext.role][config.rateLimitCategory || 'default']
      );

      setRateLimitHeaders(c, result);

      if (!result.allowed) {
        return c.json({ error: { code: 'RATE_LIMITED', message: 'Too many requests' }}, 429);
      }
    }

    // 5. Store context for route handlers
    c.set('userContext', userContext);

    await next();
  };
}

6.2 Experience API Middleware (Next.js)

// middleware.ts

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Only process API routes
  if (!pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  // Get route configuration
  const routeConfig = getRouteConfig(pathname);

  // Extract user context
  const userContext = extractUserContext(request.headers.get('authorization'));

  // Check authentication
  if (!routeConfig.allowAnonymous && userContext.role === UserRole.ANONYMOUS) {
    return NextResponse.json(
      { error: { code: 'UNAUTHORIZED', message: 'Authentication required' }},
      { status: 401 }
    );
  }

  // Check permissions
  for (const permission of routeConfig.requiredPermissions || []) {
    if (!userContext.permissions.includes(permission)) {
      return NextResponse.json(
        { error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }},
        { status: 403 }
      );
    }
  }

  // Rate limiting
  const identifier = getClientIdentifier(request, userContext);
  const result = rateLimiter.check(identifier, routeConfig.rateLimitCategory);

  const response = result.allowed
    ? NextResponse.next()
    : NextResponse.json(
        { error: { code: 'RATE_LIMITED', message: 'Too many requests' }},
        { status: 429 }
      );

  // Set headers
  setRateLimitHeaders(response, result);
  response.headers.set('X-User-Role', userContext.role);

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

6.3 Route Configuration

const ROUTE_CONFIG: Record<string, RouteProtectionConfig> = {
  // Public discovery
  '/api/discovery/*': {
    allowAnonymous: true,
    rateLimitCategory: 'content'
  },

  // Search (public but stricter limits)
  '/api/search': {
    allowAnonymous: true,
    rateLimitCategory: 'search',
    requiredPermissions: [Permission.BASIC_SEARCH],
  },
  '/api/search/advanced': {
    allowAnonymous: false,
    rateLimitCategory: 'search',
    requiredPermissions: [Permission.ADVANCED_SEARCH],
  },

  // User routes (authenticated)
  '/api/me/*': {
    allowAnonymous: false,
    requiredPermissions: [Permission.TRACK_PROGRESS],
  },

  // Premium features
  '/api/analytics/*': {
    allowAnonymous: false,
    requiredPermissions: [Permission.ACCESS_ADVANCED_ANALYTICS],
  },
};

7. Database Schema Design

7.1 Subscriptions Table

CREATE TABLE subscriptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    -- Subscription details
    plan VARCHAR(20) NOT NULL CHECK (plan IN ('free', 'pro', 'premium')),
    status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'canceled', 'past_due', 'trialing')),

    -- Stripe integration
    stripe_subscription_id VARCHAR(255),
    stripe_customer_id VARCHAR(255),
    stripe_price_id VARCHAR(255),

    -- Billing period
    current_period_start TIMESTAMPTZ NOT NULL,
    current_period_end TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN DEFAULT false,
    canceled_at TIMESTAMPTZ,

    -- Metadata
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(user_id)
);

-- Indexes
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);

-- RLS
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own subscription" ON subscriptions
    FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Service role manages subscriptions" ON subscriptions
    FOR ALL USING (auth.jwt() ->> 'role' = 'service_role');

7.2 Content Tier Columns

-- Add access_tier to sparks
ALTER TABLE sparks ADD COLUMN access_tier VARCHAR(20) DEFAULT 'free'
    CHECK (access_tier IN ('free', 'pro', 'premium'));

CREATE INDEX idx_sparks_access_tier ON sparks(access_tier);

-- Add access_tier to trails
ALTER TABLE trails ADD COLUMN access_tier VARCHAR(20) DEFAULT 'free'
    CHECK (access_tier IN ('free', 'pro', 'premium'));

CREATE INDEX idx_trails_access_tier ON trails(access_tier);

7.3 Supabase Auth Hook

-- Function to add custom claims to JWT
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
    claims jsonb;
    user_role text;
    subscription_plan text;
    is_admin boolean;
BEGIN
    -- Get user's subscription status
    SELECT s.plan INTO subscription_plan
    FROM subscriptions s
    WHERE s.user_id = (event->>'user_id')::uuid
    AND s.status = 'active'
    AND s.current_period_end > NOW();

    -- Determine role based on subscription
    user_role := COALESCE(subscription_plan, 'free');

    -- Check for admin role in user_profiles
    SELECT (role = 'admin') INTO is_admin
    FROM user_profiles
    WHERE id = (event->>'user_id')::uuid;

    IF COALESCE(is_admin, false) THEN
        user_role := 'admin';
    END IF;

    -- Add claims to JWT
    claims := event->'claims';
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
    claims := jsonb_set(claims, '{subscription_active}',
        to_jsonb(subscription_plan IS NOT NULL));
    claims := jsonb_set(claims, '{subscription_plan}',
        COALESCE(to_jsonb(subscription_plan), 'null'::jsonb));

    RETURN jsonb_set(event, '{claims}', claims);
END;
$$;

-- Grant execution to supabase_auth_admin
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM PUBLIC;

8. Stripe Integration Design

8.1 Webhook Event Handling

// routes/webhooks/stripe.ts

const STRIPE_EVENTS = [
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
  'invoice.payment_failed',
  'invoice.payment_succeeded',
];

async function handleStripeWebhook(c: Context) {
  const signature = c.req.header('stripe-signature');
  const body = await c.req.text();

  // Verify webhook signature
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature!,
      c.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return c.json({ error: 'Invalid signature' }, 400);
  }

  // Handle event
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await handleSubscriptionChange(event.data.object as Stripe.Subscription);
      break;

    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
      break;

    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object as Stripe.Invoice);
      break;
  }

  return c.json({ received: true });
}

8.2 Subscription Sync Flow

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Stripe    │────▶│   Webhook   │────▶│ subscriptions│────▶│  Auth Hook  │
│   Event     │     │   Handler   │     │    Table     │     │  JWT Claims │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
                           │                   │                    │
                           │                   │                    │
                           ▼                   ▼                    ▼
                    Verify Signature     UPDATE/INSERT         On Next Login:
                    Extract metadata     subscription row      user_role updated

8.3 Plan Mapping

const STRIPE_PLAN_MAPPING: Record<string, UserRole> = {
  'price_pro_monthly': UserRole.PRO,
  'price_pro_yearly': UserRole.PRO,
  'price_premium_monthly': UserRole.PREMIUM,
  'price_premium_yearly': UserRole.PREMIUM,
};

function mapStripePlanToRole(priceId: string): UserRole {
  return STRIPE_PLAN_MAPPING[priceId] || UserRole.FREE;
}

9. Module Structure

9.1 Directory Layout

core/
├── shared/
│   └── src/
│       ├── index.ts                      # Main exports
│       └── protection/
│           ├── index.ts                  # Module exports
│           ├── types.ts                  # TypeScript types
│           ├── roles.ts                  # Role/permission definitions
│           ├── config.ts                 # Configuration constants
│           ├── role-resolver.ts          # JWT → UserContext
│           ├── rate-limiter.ts           # Rate limiter implementation
│           ├── paywall.ts                # Paywall logic
│           └── audit.ts                  # Audit logging
├── domain/
│   └── api/
│       ├── middleware/
│       │   ├── index.ts                  # Middleware exports
│       │   ├── protection.ts             # Main protection middleware
│       │   └── paywall.ts                # Paywall middleware
│       └── routes/
│           ├── utils.ts                  # Updated with ProtectionContext
│           └── webhooks/
│               └── stripe.ts             # Stripe webhook handler
├── experience/
│   └── src/
│       ├── middleware.ts                 # Next.js Edge Middleware
│       └── lib/
│           └── protection/
│               ├── index.ts              # Protection utilities
│               ├── rate-limiter.ts       # Rate limiter instance
│               └── config.ts             # Route configurations
└── supabase/
    └── migrations/
        ├── YYYYMMDD_add_subscriptions.sql
        ├── YYYYMMDD_add_access_tier.sql
        └── YYYYMMDD_auth_hook.sql

9.2 Module Exports

// shared/src/protection/index.ts

// Types
export type {
  UserRole,
  Permission,
  UserContext,
  RateLimitConfig,
  RateLimitResult,
  PaywallResult,
} from './types';

// Constants
export { ROLE_PERMISSIONS } from './roles';
export { RATE_LIMITS, PROTECTION_CONFIG } from './config';

// Functions
export { resolveUserContext } from './role-resolver';
export { MemoryRateLimiter } from './rate-limiter';
export { checkPaywall, applyPaywall } from './paywall';
export { createAuditLog, AuditEventType } from './audit';

10. Security Considerations

10.1 JWT Validation

// Token validation rules
const JWT_VALIDATION = {
  // Required claims
  requiredClaims: ['sub', 'aud', 'exp', 'iss'],

  // Expected values
  audience: 'authenticated',
  issuerPattern: /https:\/\/.*\.supabase\.co\/auth\/v1/,

  // Clock tolerance for exp/nbf claims
  clockTolerance: 60, // seconds

  // Reject tokens older than
  maxAge: 3600, // 1 hour (matches Supabase default)
};

function validateJWT(token: string, supabaseUrl: string): boolean {
  try {
    const [, payloadB64] = token.split('.');
    const payload = JSON.parse(atob(payloadB64));

    // Check expiration
    if (payload.exp < Math.floor(Date.now() / 1000) - JWT_VALIDATION.clockTolerance) {
      return false;
    }

    // Check issuer
    if (!JWT_VALIDATION.issuerPattern.test(payload.iss)) {
      return false;
    }

    // Check audience
    if (payload.aud !== JWT_VALIDATION.audience) {
      return false;
    }

    return true;
  } catch {
    return false;
  }
}

10.2 Rate Limit Bypass Prevention

const SECURITY_CHECKS = {
  // Block suspicious IP patterns
  blockPatterns: [
    /^127\./,           // Localhost spoofing
    /^0\./,             // Invalid IPs
    /^192\.168\./,      // Private network (shouldn't reach public)
    /^10\./,            // Private network
  ],

  // Detect rapid bursts
  burstDetection: {
    threshold: 50,      // requests
    windowMs: 1000,     // 1 second
  },

  // Header validation
  requiredHeaders: ['user-agent'],
};

function isRequestSuspicious(request: Request, identifier: string): boolean {
  // Check for blocked IP patterns
  if (identifier.startsWith('ip:')) {
    const ip = identifier.slice(3);
    for (const pattern of SECURITY_CHECKS.blockPatterns) {
      if (pattern.test(ip)) {
        return true;
      }
    }
  }

  // Check for missing required headers
  for (const header of SECURITY_CHECKS.requiredHeaders) {
    if (!request.headers.get(header)) {
      return true;
    }
  }

  return false;
}

10.3 Audit Logging

enum AuditEventType {
  AUTH_SUCCESS = 'auth.success',
  AUTH_FAILURE = 'auth.failure',
  RATE_LIMITED = 'rate_limit.exceeded',
  PERMISSION_DENIED = 'permission.denied',
  PAYWALL_BLOCKED = 'paywall.blocked',
  SUSPICIOUS_ACTIVITY = 'security.suspicious',
}

interface AuditLogEntry {
  timestamp: string;
  eventType: AuditEventType;
  userId?: string;
  clientIp: string;
  userAgent?: string;
  path: string;
  method: string;
  statusCode?: number;
  details?: Record<string, unknown>;
  requestId: string;
}

function createAuditLog(context: Context, eventType: AuditEventType, details?: object): void {
  const entry: AuditLogEntry = {
    timestamp: new Date().toISOString(),
    eventType,
    userId: context.get('userContext')?.id,
    clientIp: context.req.header('x-forwarded-for')?.split(',')[0] || 'unknown',
    userAgent: context.req.header('user-agent'),
    path: context.req.path,
    method: context.req.method,
    requestId: context.get('requestId'),
    details,
  };

  // Structured log output (captured by logging infrastructure)
  console.log(JSON.stringify({ type: 'audit', ...entry }));
}

11. API Response Patterns

11.1 Success Responses

// Standard success
{
  "data": { ... },
  "meta": {
    "page": 1,
    "per_page": 20,
    "total": 100
  }
}

// With paywall metadata
{
  "data": {
    "id": "spark-uuid",
    "title": "Advanced Topic",
    "content_html": "<preview content>",
    "_paywall": {
      "previewOnly": true,
      "requiredTier": "pro",
      "upgradeMessage": "Upgrade to Pro for full access"
    }
  }
}

11.2 Error Responses

// Authentication error (401)
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

// Permission error (403)
{
  "error": {
    "code": "FORBIDDEN",
    "message": "Insufficient permissions",
    "required": "access:spaced_repetition",
    "upgrade": "Consider upgrading to Pro"
  }
}

// Rate limit error (429)
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests",
    "retryAfter": 45
  }
}

11.3 Response Headers

# All responses
X-Request-Id: uuid-here
X-User-Role: pro

# Rate limited responses
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1736496060

# 429 responses only
Retry-After: 45

12. Configuration Reference

12.1 Protection Configuration

const PROTECTION_CONFIG = {
  // Feature flag
  enabled: true,

  // JWT settings
  jwt: {
    issuer: 'supabase',
    audience: 'authenticated',
    clockTolerance: 60,
  },

  // Rate limit defaults
  rateLimit: {
    defaultWindowMs: 60_000,
    cleanupIntervalMs: 60_000,
    maxStoreEntries: 10_000,
  },

  // Paywall settings
  paywall: {
    previewPercentage: 0.3,  // 30% preview
    premiumContentMarker: 'premium',
  },

  // Feature limits per tier
  features: {
    anonymous: { maxSearchResults: 10, canTrackProgress: false },
    free: { maxSearchResults: 50, maxJourneys: 3, canTrackProgress: true },
    pro: { maxSearchResults: 100, maxJourneys: 20, canTrackProgress: true },
    premium: { maxSearchResults: Infinity, maxJourneys: Infinity },
  },
};

12.2 Environment Variables

Variable Layer Required Description
SUPABASE_URL Both Yes Supabase project URL
SUPABASE_ANON_KEY Both Yes Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY Domain Yes For subscription sync
SERVICE_AUTH_SECRET Both Yes Shared secret for service-to-service auth
DEV_SKIP_SERVICE_AUTH Domain No Skip service auth in local dev (set to "true")
STRIPE_SECRET_KEY Domain Yes Stripe API key
STRIPE_WEBHOOK_SECRET Domain Yes Webhook signature verification
UPSTASH_REDIS_REST_URL Experience Optional Upstash Redis HTTP URL for advanced rate limiting
UPSTASH_REDIS_REST_TOKEN Experience Optional Upstash Redis authentication token

13. Design Decisions

13.1 Confirmed Decisions

Decision Choice Rationale
Rate Limit Location Vercel WAF/Edge Serverless functions can't reliably maintain in-memory state
Service Authentication X-Service-Auth header Prevents direct access to Domain APIs; enforces Experience layer as gateway
Payment Provider Stripe Industry standard, robust webhook system
Rate Limit Response Block completely (429) Industry standard, predictable, includes Retry-After header
Audit Logging Structured logs to stdout Captured by Vercel/Supabase logging, no DB overhead
Role Storage JWT custom claims No DB lookup needed, role resolved at token issue time
Preview Percentage 30% Balance between tease and value
Anonymous Identification IP-based (at Vercel Edge) WAF can rate limit by IP without application code
Public API Access Via Experience layer only Service auth ensures all traffic goes through Vercel
In-Memory Rate Limiting Removed Was unreliable in serverless; replaced by Vercel WAF

13.2 Trade-offs

Trade-off Chosen Approach Alternative Why
Rate limit accuracy Multi-layer protection Single-layer in-memory Serverless requires external state for reliability
Rate limiter latency Upstash Redis (~5-10ms) In-memory (0ms) Reliability over latency; still fast enough
Role refresh latency On next login Real-time sync Simpler; subscription changes aren't instant anyway
Paywall enforcement API layer Database RLS More flexibility for preview logic
Permission model Flat permissions Hierarchical RBAC Simpler; no complex inheritance needed
Cost vs reliability External Redis service In-memory only Pay for reliability at scale

Appendix A: Quick Reference

A.1 Rate Limit Headers

X-RateLimit-Limit: Max requests per window
X-RateLimit-Remaining: Remaining requests
X-RateLimit-Reset: Unix timestamp when window resets
Retry-After: Seconds until next request allowed (429 only)

A.2 Error Codes

UNAUTHORIZED - Authentication required (401)
FORBIDDEN - Insufficient permissions (403)
RATE_LIMITED - Too many requests (429)
PAYWALL_BLOCKED - Content requires upgrade (403)

A.3 User Roles

anonymous → free → pro → premium → admin

A.4 Content Tiers

free → pro → premium

A.5 Anonymous Rate Limiting Quick Reference

How anonymous requests are rate limited without API tokens:

1. Client makes HTTP request (no Authorization header)
2. CDN/Proxy adds client IP to X-Forwarded-For header
3. Edge function extracts IP from headers
4. Rate limit checked via Upstash Redis (production) or best-effort in-memory
5. Response includes X-RateLimit-* headers

IP Extraction Priority:

1. CF-Connecting-IP (Cloudflare)
2. X-Forwarded-For (first IP)
3. X-Real-IP (nginx)
4. X-Client-IP (fallback)

Anonymous Rate Limits:

Content endpoints: 20 req/min per IP
Search endpoints:  10 req/min per IP
Default:           30 req/min per IP

Example Anonymous Request:

# No auth required - rate limited by IP
curl https://project.supabase.co/functions/v1/browse/domains

# Response headers show rate limit status
# X-RateLimit-Limit: 20
# X-RateLimit-Remaining: 19
# X-RateLimit-Reset: 1704067200

A.6 Service Authentication Quick Reference

How service authentication works:

1. Client makes request to Experience layer (Vercel)
2. Vercel WAF applies rate limiting at edge
3. Next.js middleware validates request
4. Domain client adds X-Service-Auth header with shared secret
5. Domain API verifies X-Service-Auth before processing
6. Direct requests to Supabase functions return 403

Environment Variables Required:

# Experience layer (.env.local)
SERVICE_AUTH_SECRET=your-secret-here

# Domain layer (Supabase secrets)
SERVICE_AUTH_SECRET=your-secret-here
DEV_SKIP_SERVICE_AUTH=true  # For local development

Generate a secret:

openssl rand -base64 32

A.7 Protection Layers Checklist

For Production Deployment:

✅ Configure SERVICE_AUTH_SECRET in both layers
✅ Configure Vercel WAF rate limiting in dashboard
✅ Enable Supabase DDoS protection (automatic)
✅ Set verify_jwt = true for protected functions
✅ Verify direct Supabase access returns 403
✅ Test via Experience layer succeeds

Protection Layers (Defense in Depth):

Layer 1: Vercel WAF    → Rate limiting at edge
Layer 2: Service Auth  → X-Service-Auth header verification
Layer 3: Gateway JWT   → verify_jwt in config.toml
Layer 4: Auth Check    → JWT validation and user context
Layer 5: Permissions   → Role-based access control
Layer 6: Database RLS  → Row-level security