Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/backend/src/lib/tier-enforcement.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<SubscriptionTier, number> = {
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<string, SubscriptionTier> = {
'/api/deployments/analytics': 'pro',
'/api/deployments/domains': 'pro',
'/api/branding': 'pro',
'/api/admin': 'enterprise',
};

// ── Middleware ────────────────────────────────────────────────────────────────

type RouteHandler<TParams = {}> = (
req: NextRequest,
ctx: { params: TParams }
) => Promise<NextResponse>;

/**
* 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<TParams = {}>(
requiredTier: SubscriptionTier,
handler: RouteHandler<TParams>
): RouteHandler<TParams> {
return async (req: NextRequest, ctx: { params: TParams }): Promise<NextResponse> => {
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);
};
}
153 changes: 153 additions & 0 deletions apps/backend/tests/subscription/tier-enforcement.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading