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
13 changes: 7 additions & 6 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ const MCP_BEARER_PREFIX = 'Bearer mcp_';
const SESSION_BEARER_PREFIX = 'Bearer ps_sess_';

const IS_ONPREM = process.env.DEPLOYMENT_MODE === 'onprem';
const IS_TENANT = process.env.DEPLOYMENT_MODE === 'tenant';
const IS_BILLING_DISABLED = IS_ONPREM || IS_TENANT;

/**
* Routes blocked in on-prem mode. These return 404 to prevent probing.
* Routes blocked in on-prem and tenant modes. These return 404 to prevent probing.
* Stripe, OAuth, magic-link, passkey, and self-registration routes are
* all inaccessible on-prem - the middleware blocks them before any
* route handler code executes.
* inaccessible - the middleware blocks them before any route handler executes.
*/
const ONPREM_BLOCKED_ROUTE_PREFIXES = [
const CLOUD_ONLY_ROUTE_PREFIXES = [
'/api/stripe/',
'/api/auth/google/',
'/api/auth/apple/',
Expand All @@ -43,9 +44,9 @@ export async function middleware(req: NextRequest) {
const isProduction = process.env.NODE_ENV === 'production';
const isAPIRoute = pathname.startsWith('/api');

// On-prem route blocking (defense-in-depth)
// Non-cloud route blocking (defense-in-depth)
// Runs before all other checks to prevent cloud-only routes from executing
if (IS_ONPREM && ONPREM_BLOCKED_ROUTE_PREFIXES.some(prefix => pathname.startsWith(prefix))) {
if (IS_BILLING_DISABLED && CLOUD_ONLY_ROUTE_PREFIXES.some(prefix => pathname.startsWith(prefix))) {
const { response } = createSecureResponse(isProduction, req, { isAPIRoute: true });
return new NextResponse(null, { status: 404, headers: response.headers });
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/hooks/useBillingVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import { useCapacitor } from './useCapacitor';
import { isOnPrem } from '@/lib/deployment-mode';
import { isBillingEnabled } from '@/lib/deployment-mode';

/**
* Hook to determine billing UI visibility based on platform and deployment mode.
*
* Billing is hidden on iOS Capacitor apps (Apple App Store compliance)
* and on-prem deployments (no Stripe).
* and non-cloud deployments (on-prem, tenant — no in-app Stripe).
*
* @example
* ```tsx
Expand All @@ -25,8 +25,8 @@ import { isOnPrem } from '@/lib/deployment-mode';
export function useBillingVisibility() {
const { isIOS, isReady } = useCapacitor();

// On-prem: always hide billing, immediately ready
if (isOnPrem()) {
// Non-cloud (on-prem, tenant): always hide billing, immediately ready
if (!isBillingEnabled()) {
return { showBilling: false, hideBilling: true, isReady: true };
}

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/billing/billing-visibility.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isIOS } from '@/lib/capacitor-bridge';
import { isOnPrem } from '@/lib/deployment-mode';
import { isBillingEnabled } from '@/lib/deployment-mode';

/**
* Paths that contain billing/subscription functionality.
Expand All @@ -9,12 +9,12 @@ export const BILLING_PATHS = ['/settings/billing', '/settings/plan'] as const;

/**
* Determines whether billing UI should be shown.
* Returns false for iOS Capacitor apps and on-prem deployments.
* Returns false for iOS Capacitor apps, on-prem, and tenant deployments.
*
* Use this for non-React contexts. For React components, use `useBillingVisibility` hook.
*/
export function shouldShowBilling(): boolean {
if (isOnPrem()) return false;
if (!isBillingEnabled()) return false;
return !isIOS();
}

Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/lib/deployment-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export function isOnPrem(): boolean {
return process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'onprem';
}

export function isTenantMode(): boolean {
return process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'tenant';
}

export function isCloud(): boolean {
return !isOnPrem();
return !isOnPrem() && !isTenantMode();
}

export function isBillingEnabled(): boolean {
return isCloud();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { requiresProSubscription } from '../rate-limit-middleware';

describe('Rate Limit Middleware', () => {
describe('requiresProSubscription()', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('should not require pro subscription in tenant mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
expect(requiresProSubscription('pagespace', 'pro-model', 'free')).toBe(false);
});

it('should not require pro subscription in onprem mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'onprem');
expect(requiresProSubscription('pagespace', 'pro-model', 'free')).toBe(false);
});

it('should not require pro subscription for non-pro model in cloud mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
expect(requiresProSubscription('pagespace', undefined, 'free')).toBe(false);
});

it('should require pro subscription for pro model with free tier in cloud mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
expect(requiresProSubscription('pagespace', 'glm-5', 'free')).toBe(true);
});

it('should not require pro subscription for business tier in cloud mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
expect(requiresProSubscription('pagespace', 'some-model', 'business')).toBe(false);
});

it('should not require pro subscription for pro tier in cloud mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
expect(requiresProSubscription('pagespace', 'some-model', 'pro')).toBe(false);
});
});
});
27 changes: 26 additions & 1 deletion apps/web/src/lib/subscription/__tests__/usage-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getUsageLimits } from '../usage-service';

// Note: incrementUsage, getCurrentUsage, and getUserUsageSummary require database
Expand Down Expand Up @@ -59,6 +59,31 @@ describe('Usage Service', () => {
});
});

describe('tenant deployment mode', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('should return unlimited (-1) for any tier in tenant mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
expect(getUsageLimits('free', 'standard')).toBe(-1);
expect(getUsageLimits('free', 'pro')).toBe(-1);
expect(getUsageLimits('business', 'standard')).toBe(-1);
});

it('should return unlimited (-1) for onprem mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'onprem');
expect(getUsageLimits('free', 'standard')).toBe(-1);
expect(getUsageLimits('free', 'pro')).toBe(-1);
});

it('should enforce limits in cloud mode', () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
expect(getUsageLimits('free', 'standard')).toBe(50);
expect(getUsageLimits('free', 'pro')).toBe(0);
});
});

describe('Usage Limit Business Rules', () => {
it('should give free tier users no access to pro AI', () => {
// Business rule: Free users cannot use pro AI models
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/subscription/rate-limit-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { incrementUsage } from './usage-service';
import { getTomorrowMidnightUTC, isOnPrem, type ProviderType } from '@pagespace/lib';
import { getTomorrowMidnightUTC, isBillingEnabled, type ProviderType } from '@pagespace/lib';
import { getPageSpaceModelTier } from '@/lib/ai/core/ai-providers-config';

export interface RateLimitResult {
Expand Down Expand Up @@ -90,10 +90,10 @@ export function createRateLimitResponse(

/**
* Check if provider requires Pro subscription.
* On-prem: no subscription gating.
* Billing-disabled deployments (on-prem and tenant) bypass subscription gating via isBillingEnabled().
*/
export function requiresProSubscription(provider: string, model: string | undefined, subscriptionTier: string | undefined): boolean {
if (isOnPrem()) return false;
if (!isBillingEnabled()) return false;

// Check if model is a Pro tier model using centralized alias config
const modelTier = provider === 'pagespace' && model ? getPageSpaceModelTier(model) : null;
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/lib/subscription/usage-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db, eq, users } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { isOnPrem } from '@pagespace/lib';
import { isBillingEnabled } from '@pagespace/lib';
import { maskIdentifier } from '@/lib/logging/mask';
import { rateLimitCache, type ProviderType, type UsageTrackingResult } from '@pagespace/lib';

Expand All @@ -12,10 +12,10 @@ const verboseUsageLogging = process.env.AI_DEBUG_LOGGING === 'true' || process.e

/**
* Get usage limits based on subscription tier.
* Returns -1 (unlimited) for on-prem deployments.
* Returns -1 (unlimited) when billing is disabled.
*/
export function getUsageLimits(subscriptionTier: string, providerType: ProviderType): number {
if (isOnPrem()) return -1;
if (!isBillingEnabled()) return -1;

if (providerType === 'standard') {
// Standard AI calls per day by tier
Expand Down Expand Up @@ -78,7 +78,7 @@ export async function incrementUsage(
});
}

// Unlimited (on-prem)
// Unlimited (billing disabled)
if (limit === -1) {
return { success: true, currentCount: 0, limit: -1, remainingCalls: -1 };
}
Expand Down Expand Up @@ -142,7 +142,7 @@ export async function getCurrentUsage(
const subscriptionTier = user[0].subscriptionTier;
const limit = getUsageLimits(subscriptionTier, providerType);

// Unlimited (on-prem)
// Unlimited (billing disabled)
if (limit === -1) {
return { success: true, currentCount: 0, limit: -1, remainingCalls: -1 };
}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/middleware/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const buildCSPString = (directives: CSPDirectives): string =>
.map(([key, values]) => `${key} ${values.join(' ')}`)
.join('; ');

const IS_ONPREM = process.env.DEPLOYMENT_MODE === 'onprem';
const IS_CLOUD = process.env.DEPLOYMENT_MODE !== 'onprem' && process.env.DEPLOYMENT_MODE !== 'tenant';

export const buildCSPPolicy = (nonce: string): string => {
const scriptSrc = [
Expand All @@ -54,8 +54,8 @@ export const buildCSPPolicy = (nonce: string): string => {
const styleSrc = ["'self'", "'unsafe-inline'"];
const frameSrc: string[] = [];

// On-prem: no Google or Stripe external origins needed
if (!IS_ONPREM) {
// Cloud-only: Google and Stripe external origins
if (IS_CLOUD) {
scriptSrc.push('https://accounts.google.com');
styleSrc.push('https://accounts.google.com');
frameSrc.push('https://accounts.google.com', 'https://js.stripe.com');
Expand All @@ -64,7 +64,7 @@ export const buildCSPPolicy = (nonce: string): string => {
const connectSrc = ["'self'", 'ws:', 'wss:'];

// Cloud mode: allow Stripe client SDK and Google One Tap connections
if (!IS_ONPREM) {
if (IS_CLOUD) {
connectSrc.push('https://accounts.google.com', 'https://*.stripe.com');
}

Expand Down
109 changes: 109 additions & 0 deletions packages/lib/src/__tests__/deployment-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('deployment-mode', () => {
beforeEach(() => {
vi.unstubAllEnvs();
});

afterEach(() => {
vi.unstubAllEnvs();
});

// Dynamic import to pick up env changes per test
async function loadModule() {
vi.resetModules();
return import('../deployment-mode');
}

describe('isTenantMode', () => {
it('given DEPLOYMENT_MODE=tenant, should return true', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
const { isTenantMode } = await loadModule();
expect(isTenantMode()).toBe(true);
});

it('given DEPLOYMENT_MODE=cloud, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
const { isTenantMode } = await loadModule();
expect(isTenantMode()).toBe(false);
});

it('given DEPLOYMENT_MODE=onprem, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'onprem');
const { isTenantMode } = await loadModule();
expect(isTenantMode()).toBe(false);
});

it('given DEPLOYMENT_MODE is unset, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', '');
const { isTenantMode } = await loadModule();
expect(isTenantMode()).toBe(false);
});
});

describe('isCloud', () => {
it('given DEPLOYMENT_MODE=tenant, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
const { isCloud } = await loadModule();
expect(isCloud()).toBe(false);
});

it('given DEPLOYMENT_MODE=cloud, should return true', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
const { isCloud } = await loadModule();
expect(isCloud()).toBe(true);
});

it('given DEPLOYMENT_MODE unset, should return true', async () => {
vi.stubEnv('DEPLOYMENT_MODE', '');
const { isCloud } = await loadModule();
expect(isCloud()).toBe(true);
});
});

describe('isOnPrem', () => {
it('given DEPLOYMENT_MODE=onprem, should return true', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'onprem');
const { isOnPrem } = await loadModule();
expect(isOnPrem()).toBe(true);
});

it('given DEPLOYMENT_MODE=tenant, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
const { isOnPrem } = await loadModule();
expect(isOnPrem()).toBe(false);
});

it('given DEPLOYMENT_MODE=cloud, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
const { isOnPrem } = await loadModule();
expect(isOnPrem()).toBe(false);
});

it('given DEPLOYMENT_MODE is unset, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', '');
const { isOnPrem } = await loadModule();
expect(isOnPrem()).toBe(false);
});
});

describe('isBillingEnabled', () => {
it('given DEPLOYMENT_MODE=tenant, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'tenant');
const { isBillingEnabled } = await loadModule();
expect(isBillingEnabled()).toBe(false);
});

it('given DEPLOYMENT_MODE=cloud, should return true', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'cloud');
const { isBillingEnabled } = await loadModule();
expect(isBillingEnabled()).toBe(true);
});

it('given DEPLOYMENT_MODE=onprem, should return false', async () => {
vi.stubEnv('DEPLOYMENT_MODE', 'onprem');
const { isBillingEnabled } = await loadModule();
expect(isBillingEnabled()).toBe(false);
});
});
});
Loading
Loading