From 101899f9e97d9dad7b313fe09f7b9ede58b94546 Mon Sep 17 00:00:00 2001 From: Darkdruce Date: Fri, 26 Jun 2026 13:07:55 +0000 Subject: [PATCH] feat(auth): add subscription tier enforcement middleware with granular feature gates Closes #767 --- .../src/lib/tier-enforcement.middleware.ts | 94 +++++++++++ .../subscription/tier-enforcement.test.ts | 153 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 apps/backend/src/lib/tier-enforcement.middleware.ts create mode 100644 apps/backend/tests/subscription/tier-enforcement.test.ts diff --git a/apps/backend/src/lib/tier-enforcement.middleware.ts b/apps/backend/src/lib/tier-enforcement.middleware.ts new file mode 100644 index 00000000..6cbd2531 --- /dev/null +++ b/apps/backend/src/lib/tier-enforcement.middleware.ts @@ -0,0 +1,94 @@ +/** + * Subscription Tier Enforcement Middleware + * + * Centralises feature-gate evaluation. Reads the user's current tier fresh + * from Supabase on every request (not from the JWT cache) so in-flight + * upgrades are reflected immediately. + * + * Usage: + * export const GET = withTierEnforcement('pro', handler); + * + * Returns 402 Payment Required with an upgrade URL when the user's tier + * is below the required minimum. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import type { SubscriptionTier } from '@craft/types'; + +// ── Tier ordering ───────────────────────────────────────────────────────────── + +const TIER_ORDER: Record = { + free: 0, + pro: 1, + enterprise: 2, +}; + +// ── Feature gate config ─────────────────────────────────────────────────────── + +/** + * Declarative map from route-pattern substrings to the minimum required tier. + * Consumed by callers to know which tier to pass to withTierEnforcement. + */ +export const FEATURE_GATES: Record = { + '/api/deployments/analytics': 'pro', + '/api/deployments/domains': 'pro', + '/api/branding': 'pro', + '/api/admin': 'enterprise', +}; + +// ── Middleware ──────────────────────────────────────────────────────────────── + +type RouteHandler = ( + req: NextRequest, + ctx: { params: TParams } +) => Promise; + +/** + * Wraps a route handler with a tier check. + * + * Re-reads the user's subscription_tier from the profiles table on every + * invocation to reflect in-flight upgrades within <5 ms (single indexed + * SELECT on a small row). + * + * Returns 401 if unauthenticated. + * Returns 402 with an upgradeUrl if the user's tier is below requiredTier. + */ +export function withTierEnforcement( + requiredTier: SubscriptionTier, + handler: RouteHandler +): RouteHandler { + return async (req: NextRequest, ctx: { params: TParams }): Promise => { + const supabase = createClient(); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Re-read tier from DB — intentionally not trusting the JWT claim. + const { data: profile } = await supabase + .from('profiles') + .select('subscription_tier') + .eq('id', user.id) + .single(); + + const userTier = (profile?.subscription_tier ?? 'free') as SubscriptionTier; + + if (TIER_ORDER[userTier] < TIER_ORDER[requiredTier]) { + return NextResponse.json( + { + error: `This feature requires a ${requiredTier} subscription or higher.`, + upgradeUrl: '/pricing', + }, + { status: 402 } + ); + } + + return handler(req, ctx); + }; +} diff --git a/apps/backend/tests/subscription/tier-enforcement.test.ts b/apps/backend/tests/subscription/tier-enforcement.test.ts new file mode 100644 index 00000000..b3f1540a --- /dev/null +++ b/apps/backend/tests/subscription/tier-enforcement.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +/** + * Tests for subscription tier enforcement middleware (#767) + * + * Covers: + * - Free tier can access free routes + * - Free tier is blocked from pro routes (402) + * - Pro tier can access pro routes + * - Pro tier is blocked from enterprise routes (402) + * - Enterprise tier can access all routes + * - 402 response contains upgradeUrl + * - Tier is re-read from Supabase (not JWT) + * - Unauthenticated requests get 401 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; + +// ── Supabase mock factory ───────────────────────────────────────────────────── + +function makeSupabaseMock(tier: string | null, authenticated = true) { + return { + auth: { + getUser: vi.fn().mockResolvedValue({ + data: { user: authenticated ? { id: 'user-1' } : null }, + error: authenticated ? null : new Error('unauthenticated'), + }), + }, + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: tier !== null ? { subscription_tier: tier } : null, + error: null, + }), + }), + }; +} + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})); + +import { createClient } from '@/lib/supabase/server'; +const mockCreateClient = vi.mocked(createClient); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRequest(path = '/api/deployments'): NextRequest { + return new NextRequest(`http://localhost${path}`, { method: 'GET' }); +} + +const okHandler = vi.fn().mockResolvedValue(NextResponse.json({ ok: true })); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('withTierEnforcement', () => { + beforeEach(() => { + vi.resetModules(); + okHandler.mockClear(); + }); + + async function load() { + const { withTierEnforcement } = await import('@/lib/tier-enforcement.middleware'); + return withTierEnforcement; + } + + it('allows free-tier user to access a free-required route', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('free') as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('free', okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(200); + expect(okHandler).toHaveBeenCalledOnce(); + }); + + it('blocks free-tier user from a pro-required route with 402', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('free') as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('pro', okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(402); + expect(okHandler).not.toHaveBeenCalled(); + }); + + it('allows pro-tier user to access a pro-required route', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('pro') as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('pro', okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(200); + expect(okHandler).toHaveBeenCalledOnce(); + }); + + it('blocks pro-tier user from an enterprise-required route with 402', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('pro') as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('enterprise', okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(402); + expect(okHandler).not.toHaveBeenCalled(); + }); + + it('allows enterprise-tier user to access any route', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('enterprise') as any); + const withTierEnforcement = await load(); + + for (const tier of ['free', 'pro', 'enterprise'] as const) { + const handler = withTierEnforcement(tier, okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(200); + } + }); + + it('includes upgradeUrl in 402 response body', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock('free') as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('pro', okHandler); + const res = await handler(makeRequest(), { params: {} }); + const body = await res.json(); + expect(body).toHaveProperty('upgradeUrl', '/pricing'); + }); + + it('re-reads tier from Supabase, not JWT', async () => { + const mock = makeSupabaseMock('pro'); + mockCreateClient.mockReturnValue(mock as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('pro', okHandler); + await handler(makeRequest(), { params: {} }); + // Verify the profiles table was queried + expect(mock.from).toHaveBeenCalledWith('profiles'); + }); + + it('returns 401 for unauthenticated requests', async () => { + mockCreateClient.mockReturnValue(makeSupabaseMock(null, false) as any); + const withTierEnforcement = await load(); + const handler = withTierEnforcement('free', okHandler); + const res = await handler(makeRequest(), { params: {} }); + expect(res.status).toBe(401); + expect(okHandler).not.toHaveBeenCalled(); + }); + + it('defaults to free tier when profile row is missing', async () => { + const mock = makeSupabaseMock(null); // null tier → no profile row + mockCreateClient.mockReturnValue(mock as any); + const withTierEnforcement = await load(); + + // free required → should pass (null treated as free) + const freeHandler = withTierEnforcement('free', okHandler); + const res = await freeHandler(makeRequest(), { params: {} }); + expect(res.status).toBe(200); + }); +});