feat: Implement user tier-based feature gating system (refs #8)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Add subscription tier system to gate features behind Free/Pro/Enterprise tiers. Backend: - Create feature-tiers.ts with FEATURE_TIERS config and utilities - Add /api/config/feature-tiers endpoint for frontend config fetch - Create requireTier middleware for route-level tier enforcement - Add subscriptionTier to request.userContext in auth plugin - Gate scanForMaintenance in documents controller (Pro+ required) - Add migration to reset scanForMaintenance for free users Frontend: - Create useTierAccess hook for tier checking - Create UpgradeRequiredDialog component (responsive) - Gate DocumentForm checkbox with lock icon for free users - Add SubscriptionTier type to profile.types.ts Documentation: - Add TIER-GATING.md with usage guide Tests: 30 passing (feature-tiers, tier-guard, controller) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
18
backend/src/core/config/config.routes.ts
Normal file
18
backend/src/core/config/config.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @ai-summary Configuration API routes
|
||||
* @ai-context Exposes feature tier configuration for frontend consumption
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { getAllFeatureConfigs, TIER_LEVELS } from './feature-tiers';
|
||||
|
||||
export const configRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /api/config/feature-tiers - Get all feature tier configurations
|
||||
// Public endpoint - no auth required (config is not sensitive)
|
||||
fastify.get('/config/feature-tiers', async (_request, reply) => {
|
||||
return reply.code(200).send({
|
||||
tiers: TIER_LEVELS,
|
||||
features: getAllFeatureConfigs(),
|
||||
});
|
||||
});
|
||||
};
|
||||
73
backend/src/core/config/feature-tiers.ts
Normal file
73
backend/src/core/config/feature-tiers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @ai-summary Feature tier configuration and utilities
|
||||
* @ai-context Defines feature-to-tier mapping for gating premium features
|
||||
*/
|
||||
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
|
||||
// Tier hierarchy: higher number = higher access level
|
||||
export const TIER_LEVELS: Record<SubscriptionTier, number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
enterprise: 2,
|
||||
} as const;
|
||||
|
||||
// Feature configuration interface
|
||||
export interface FeatureConfig {
|
||||
minTier: SubscriptionTier;
|
||||
name: string;
|
||||
upgradePrompt: string;
|
||||
}
|
||||
|
||||
// Feature registry - add new gated features here
|
||||
export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
||||
'document.scanMaintenanceSchedule': {
|
||||
minTier: 'pro',
|
||||
name: 'Scan for Maintenance Schedule',
|
||||
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get numeric level for a subscription tier
|
||||
*/
|
||||
export function getTierLevel(tier: SubscriptionTier): number {
|
||||
return TIER_LEVELS[tier] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user tier can access a feature
|
||||
* Higher tiers inherit access to all lower tier features
|
||||
*/
|
||||
export function canAccessFeature(userTier: SubscriptionTier, featureKey: string): boolean {
|
||||
const feature = FEATURE_TIERS[featureKey];
|
||||
if (!feature) {
|
||||
// Unknown features are accessible by all (fail open for unlisted features)
|
||||
return true;
|
||||
}
|
||||
return getTierLevel(userTier) >= getTierLevel(feature.minTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum required tier for a feature
|
||||
* Returns null if feature is not gated
|
||||
*/
|
||||
export function getRequiredTier(featureKey: string): SubscriptionTier | null {
|
||||
const feature = FEATURE_TIERS[featureKey];
|
||||
return feature?.minTier ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full feature configuration
|
||||
* Returns undefined if feature is not registered
|
||||
*/
|
||||
export function getFeatureConfig(featureKey: string): FeatureConfig | undefined {
|
||||
return FEATURE_TIERS[featureKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feature configurations (for API endpoint)
|
||||
*/
|
||||
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
||||
return { ...FEATURE_TIERS };
|
||||
}
|
||||
104
backend/src/core/config/tests/feature-tiers.test.ts
Normal file
104
backend/src/core/config/tests/feature-tiers.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
TIER_LEVELS,
|
||||
FEATURE_TIERS,
|
||||
getTierLevel,
|
||||
canAccessFeature,
|
||||
getRequiredTier,
|
||||
getFeatureConfig,
|
||||
getAllFeatureConfigs,
|
||||
} from '../feature-tiers';
|
||||
|
||||
describe('feature-tiers', () => {
|
||||
describe('TIER_LEVELS', () => {
|
||||
it('defines correct tier hierarchy', () => {
|
||||
expect(TIER_LEVELS.free).toBe(0);
|
||||
expect(TIER_LEVELS.pro).toBe(1);
|
||||
expect(TIER_LEVELS.enterprise).toBe(2);
|
||||
});
|
||||
|
||||
it('enterprise > pro > free', () => {
|
||||
expect(TIER_LEVELS.enterprise).toBeGreaterThan(TIER_LEVELS.pro);
|
||||
expect(TIER_LEVELS.pro).toBeGreaterThan(TIER_LEVELS.free);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FEATURE_TIERS', () => {
|
||||
it('includes scanMaintenanceSchedule feature', () => {
|
||||
const feature = FEATURE_TIERS['document.scanMaintenanceSchedule'];
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature.minTier).toBe('pro');
|
||||
expect(feature.name).toBe('Scan for Maintenance Schedule');
|
||||
expect(feature.upgradePrompt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTierLevel', () => {
|
||||
it('returns correct level for each tier', () => {
|
||||
expect(getTierLevel('free')).toBe(0);
|
||||
expect(getTierLevel('pro')).toBe(1);
|
||||
expect(getTierLevel('enterprise')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for unknown tier', () => {
|
||||
expect(getTierLevel('unknown' as any)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAccessFeature', () => {
|
||||
const featureKey = 'document.scanMaintenanceSchedule';
|
||||
|
||||
it('denies access for free tier to pro feature', () => {
|
||||
expect(canAccessFeature('free', featureKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows access for pro tier to pro feature', () => {
|
||||
expect(canAccessFeature('pro', featureKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows access for enterprise tier to pro feature (inheritance)', () => {
|
||||
expect(canAccessFeature('enterprise', featureKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows access for unknown feature (fail open)', () => {
|
||||
expect(canAccessFeature('free', 'unknown.feature')).toBe(true);
|
||||
expect(canAccessFeature('pro', 'unknown.feature')).toBe(true);
|
||||
expect(canAccessFeature('enterprise', 'unknown.feature')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequiredTier', () => {
|
||||
it('returns required tier for known feature', () => {
|
||||
expect(getRequiredTier('document.scanMaintenanceSchedule')).toBe('pro');
|
||||
});
|
||||
|
||||
it('returns null for unknown feature', () => {
|
||||
expect(getRequiredTier('unknown.feature')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureConfig', () => {
|
||||
it('returns full config for known feature', () => {
|
||||
const config = getFeatureConfig('document.scanMaintenanceSchedule');
|
||||
expect(config).toEqual({
|
||||
minTier: 'pro',
|
||||
name: 'Scan for Maintenance Schedule',
|
||||
upgradePrompt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for unknown feature', () => {
|
||||
expect(getFeatureConfig('unknown.feature')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFeatureConfigs', () => {
|
||||
it('returns copy of all feature configs', () => {
|
||||
const configs = getAllFeatureConfigs();
|
||||
expect(configs['document.scanMaintenanceSchedule']).toBeDefined();
|
||||
|
||||
// Verify it's a copy, not the original
|
||||
configs['test'] = { minTier: 'free', name: 'test', upgradePrompt: '' };
|
||||
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { logger } from '../logging/logger';
|
||||
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
|
||||
import { pool } from '../config/database';
|
||||
import { auth0ManagementClient } from '../auth/auth0-management.client';
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
|
||||
// Routes that don't require email verification
|
||||
const VERIFICATION_EXEMPT_ROUTES = [
|
||||
@@ -56,6 +57,7 @@ declare module 'fastify' {
|
||||
onboardingCompleted: boolean;
|
||||
isAdmin: boolean;
|
||||
adminRecord?: any;
|
||||
subscriptionTier: SubscriptionTier;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -129,6 +131,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
let displayName: string | undefined;
|
||||
let emailVerified = false;
|
||||
let onboardingCompleted = false;
|
||||
let subscriptionTier: SubscriptionTier = 'free';
|
||||
|
||||
try {
|
||||
// If JWT doesn't have email, fetch from Auth0 Management API
|
||||
@@ -170,6 +173,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
displayName = profile.displayName || undefined;
|
||||
emailVerified = profile.emailVerified;
|
||||
onboardingCompleted = profile.onboardingCompletedAt !== null;
|
||||
subscriptionTier = profile.subscriptionTier || 'free';
|
||||
|
||||
// Sync email verification status from Auth0 if needed
|
||||
if (!emailVerified) {
|
||||
@@ -208,6 +212,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
emailVerified,
|
||||
onboardingCompleted,
|
||||
isAdmin: false, // Default to false; admin status checked by admin guard
|
||||
subscriptionTier,
|
||||
};
|
||||
|
||||
// Email verification guard - block unverified users from non-exempt routes
|
||||
|
||||
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
205
backend/src/core/plugins/tests/tier-guard.plugin.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import tierGuardPlugin from '../tier-guard.plugin';
|
||||
|
||||
const createReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => {
|
||||
return {
|
||||
sent: false,
|
||||
code: jest.fn(function(this: any, status: number) {
|
||||
this.statusCode = status;
|
||||
return this;
|
||||
}),
|
||||
send: jest.fn(function(this: any, payload: unknown) {
|
||||
this.payload = payload;
|
||||
this.sent = true;
|
||||
return this;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
describe('tier guard plugin', () => {
|
||||
let fastify: FastifyInstance;
|
||||
let authenticateMock: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
|
||||
// Mock authenticate to set userContext
|
||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'free',
|
||||
};
|
||||
});
|
||||
fastify.decorate('authenticate', authenticateMock);
|
||||
|
||||
await fastify.register(tierGuardPlugin);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('requireTier with minTier', () => {
|
||||
it('allows access when user tier meets minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(authenticateMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
expect(reply.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access when user tier exceeds minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'enterprise',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies access when user tier is below minimum', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireTier with featureKey', () => {
|
||||
it('denies free tier access to pro feature', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier: 'pro',
|
||||
currentTier: 'free',
|
||||
feature: 'document.scanMaintenanceSchedule',
|
||||
featureName: 'Scan for Maintenance Schedule',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('allows pro tier access to pro feature', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false,
|
||||
subscriptionTier: 'pro',
|
||||
};
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows access for unknown feature (fail open)', async () => {
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ featureKey: 'unknown.feature' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns 500 when authenticate handler is not a function', async () => {
|
||||
const brokenFastify = Fastify();
|
||||
// Decorate with a non-function value to simulate missing handler
|
||||
brokenFastify.decorate('authenticate', 'not-a-function' as any);
|
||||
await brokenFastify.register(tierGuardPlugin);
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = brokenFastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(500);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'Internal server error',
|
||||
message: 'Authentication handler missing',
|
||||
})
|
||||
);
|
||||
|
||||
await brokenFastify.close();
|
||||
});
|
||||
|
||||
it('defaults to free tier when userContext is missing', async () => {
|
||||
authenticateMock.mockImplementation(async () => {
|
||||
// Don't set userContext
|
||||
});
|
||||
|
||||
const request = {} as FastifyRequest;
|
||||
const reply = createReply();
|
||||
|
||||
const handler = fastify.requireTier({ minTier: 'pro' });
|
||||
await handler(request, reply as FastifyReply);
|
||||
|
||||
expect(reply.code).toHaveBeenCalledWith(403);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentTier: 'free',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
126
backend/src/core/plugins/tier-guard.plugin.ts
Normal file
126
backend/src/core/plugins/tier-guard.plugin.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @ai-summary Fastify tier authorization plugin
|
||||
* @ai-context Enforces subscription tier requirements for protected routes
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { logger } from '../logging/logger';
|
||||
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
|
||||
import { canAccessFeature, getFeatureConfig, getTierLevel } from '../config/feature-tiers';
|
||||
|
||||
// Tier check options
|
||||
export interface TierCheckOptions {
|
||||
minTier?: SubscriptionTier;
|
||||
featureKey?: string;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
requireTier: (options: TierCheckOptions) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const tierGuardPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
/**
|
||||
* Creates a preHandler that enforces tier requirements
|
||||
*
|
||||
* Usage:
|
||||
* fastify.get('/premium-route', {
|
||||
* preHandler: [fastify.requireTier({ minTier: 'pro' })],
|
||||
* handler: controller.method
|
||||
* });
|
||||
*
|
||||
* Or with feature key:
|
||||
* fastify.post('/documents', {
|
||||
* preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
||||
* handler: controller.method
|
||||
* });
|
||||
*/
|
||||
fastify.decorate('requireTier', function(this: FastifyInstance, options: TierCheckOptions) {
|
||||
const { minTier, featureKey } = options;
|
||||
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
try {
|
||||
// Ensure user is authenticated first
|
||||
if (typeof this.authenticate !== 'function') {
|
||||
logger.error('Tier guard: authenticate handler missing');
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Authentication handler missing',
|
||||
});
|
||||
}
|
||||
|
||||
await this.authenticate(request, reply);
|
||||
if (reply.sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's subscription tier from context
|
||||
const userTier = request.userContext?.subscriptionTier || 'free';
|
||||
|
||||
// Determine required tier and check access
|
||||
let hasAccess = false;
|
||||
let requiredTier: SubscriptionTier = 'free';
|
||||
let upgradePrompt: string | undefined;
|
||||
let featureName: string | undefined;
|
||||
|
||||
if (featureKey) {
|
||||
// Feature-based tier check
|
||||
hasAccess = canAccessFeature(userTier, featureKey);
|
||||
const config = getFeatureConfig(featureKey);
|
||||
requiredTier = config?.minTier || 'pro';
|
||||
upgradePrompt = config?.upgradePrompt;
|
||||
featureName = config?.name;
|
||||
} else if (minTier) {
|
||||
// Direct tier comparison
|
||||
hasAccess = getTierLevel(userTier) >= getTierLevel(minTier);
|
||||
requiredTier = minTier;
|
||||
} else {
|
||||
// No tier requirement specified - allow access
|
||||
hasAccess = true;
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn('Tier guard: user tier insufficient', {
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
userTier,
|
||||
requiredTier,
|
||||
featureKey,
|
||||
});
|
||||
|
||||
return reply.code(403).send({
|
||||
error: 'TIER_REQUIRED',
|
||||
requiredTier,
|
||||
currentTier: userTier,
|
||||
feature: featureKey || null,
|
||||
featureName: featureName || null,
|
||||
upgradePrompt: upgradePrompt || `Upgrade to ${requiredTier} to access this feature.`,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('Tier guard: access granted', {
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
userTier,
|
||||
featureKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Tier guard: authorization check failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Tier check failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default fp(tierGuardPlugin, {
|
||||
name: 'tier-guard-plugin',
|
||||
// Note: Requires auth-plugin to be registered first for authenticate decorator
|
||||
// Dependency check removed to allow testing with mock authenticate
|
||||
});
|
||||
Reference in New Issue
Block a user