authentication authorization

This commit is contained in:
2025-12-26 15:32:22 +01:00
parent 68ae9da22a
commit 64377de548
54 changed files with 2833 additions and 95 deletions

View File

@@ -11,6 +11,7 @@ import { LeagueModule } from './domain/league/LeagueModule';
import { LoggingModule } from './domain/logging/LoggingModule';
import { MediaModule } from './domain/media/MediaModule';
import { PaymentsModule } from './domain/payments/PaymentsModule';
import { PolicyModule } from './domain/policy/PolicyModule';
import { ProtestsModule } from './domain/protests/ProtestsModule';
import { RaceModule } from './domain/race/RaceModule';
import { SponsorModule } from './domain/sponsor/SponsorModule';
@@ -33,6 +34,7 @@ import { TeamModule } from './domain/team/TeamModule';
DriverModule,
MediaModule,
PaymentsModule,
PolicyModule,
],
})
export class AppModule {}

View File

@@ -1,3 +1,8 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
@@ -6,6 +11,11 @@ import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('AnalyticsController', () => {
let controller: AnalyticsController;
@@ -109,4 +119,83 @@ describe('AnalyticsController', () => {
expect(result).toEqual(dto);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AnalyticsController],
providers: [
{
provide: AnalyticsService,
useValue: {
recordPageView: vi.fn(async () => ({})),
recordEngagement: vi.fn(async () => ({})),
getDashboardData: vi.fn(async () => ({})),
getAnalyticsMetrics: vi.fn(async () => ({})),
},
},
],
})
.overrideProvider(AnalyticsController)
.useFactory({
factory: (analyticsService: AnalyticsService) => new AnalyticsController(analyticsService),
inject: [AnalyticsService],
})
.compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).post('/analytics/page-view').send({}).expect(201);
});
it('denies internal dashboard endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/analytics/dashboard').expect(401);
});
it('allows internal dashboard endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/analytics/dashboard').expect(200);
});
});
});

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Res, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Res, HttpStatus, Inject } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger';
import type { Response } from 'express';
import { Public } from '../auth/Public';
import { RecordPageViewInputDTO } from './dtos/RecordPageViewInputDTO';
import { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO';
@@ -16,9 +17,10 @@ type RecordEngagementInput = RecordEngagementInputDTO;
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly analyticsService: AnalyticsService,
@Inject(AnalyticsService) private readonly analyticsService: AnalyticsService,
) {}
@Public()
@Post('page-view')
@ApiOperation({ summary: 'Record a page view' })
@ApiBody({ type: RecordPageViewInputDTO })
@@ -31,6 +33,7 @@ export class AnalyticsController {
res.status(HttpStatus.CREATED).json(dto);
}
@Public()
@Post('engagement')
@ApiOperation({ summary: 'Record an engagement event' })
@ApiBody({ type: RecordEngagementInputDTO })
@@ -43,6 +46,7 @@ export class AnalyticsController {
res.status(HttpStatus.CREATED).json(dto);
}
// Dashboard/metrics are internal and should not be publicly exposed.
@Get('dashboard')
@ApiOperation({ summary: 'Get analytics dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })

View File

@@ -1,8 +1,18 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { Mock, vi } from 'vitest';
import { AuthController } from './AuthController';
import { AuthService } from './AuthService';
import { AuthSessionDTO, LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import type { AuthorizationService } from './AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('AuthController', () => {
let controller: AuthController;
@@ -107,4 +117,72 @@ describe('AuthController', () => {
expect(result).toEqual(dto);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: {
getCurrentSession: vi.fn(async () => null),
loginWithEmail: vi.fn(),
signupWithEmail: vi.fn(),
logout: vi.fn(),
startIracingAuth: vi.fn(),
iracingCallback: vi.fn(),
},
},
],
})
.overrideProvider(AuthController)
.useFactory({
factory: (authService: AuthService) => new AuthController(authService),
inject: [AuthService],
})
.compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/auth/session').expect(200);
});
});
});

View File

@@ -1,11 +1,13 @@
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { Controller, Get, Post, Body, Query, Inject } from '@nestjs/common';
import { Public } from './Public';
import { AuthService } from './AuthService';
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
@Public()
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('signup')
async signup(@Body() params: SignupParamsDTO): Promise<AuthSessionDTO> {

View File

@@ -0,0 +1,164 @@
import 'reflect-metadata';
import { Controller, Get } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import { AuthorizationService } from './AuthorizationService';
import { Public } from './Public';
import { RequireRoles } from './RequireRoles';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import { PolicyService, type PolicySnapshot } from '../policy/PolicyService';
import { RequireCapability } from '../policy/RequireCapability';
@Controller('authz-test')
class AuthzTestController {
@Public()
@Get('public')
publicRoute() {
return { ok: true };
}
@Get('protected')
protectedRoute() {
return { ok: true };
}
@RequireRoles('admin')
@Get('admin')
adminOnlyRoute() {
return { ok: true };
}
@RequireCapability('demo.feature', 'view')
@Get('feature')
featureGatedRoute() {
return { ok: true };
}
}
type SessionPort = {
getCurrentSession: () => Promise<null | { token: string; user: { id: string } }>;
};
describe('Auth guards (HTTP)', () => {
let app: any;
const sessionPort: SessionPort = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'demo.feature': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [AuthzTestController],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows `@Public()` routes without a session', async () => {
await request(app.getHttpServer()).get('/authz-test/public').expect(200).expect({ ok: true });
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
});
it('denies non-public routes by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/authz-test/protected').expect(401);
});
it('allows non-public routes when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/authz-test/protected').expect(200).expect({ ok: true });
});
it('returns 403 when route requires a role and the user does not have it', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/authz-test/admin').expect(403);
});
it('allows access when route requires a role and the user has it', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
await request(app.getHttpServer()).get('/authz-test/admin').expect(200).expect({ ok: true });
});
it('returns 404 when a `@RequireCapability()` feature is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'demo.feature': 'disabled' },
loadedFrom: 'env',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/authz-test/feature').expect(404);
});
it('returns 503 during maintenance when capability is not allowlisted', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'maintenance',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'demo.feature': 'enabled' },
loadedFrom: 'env',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/authz-test/feature').expect(503);
});
});

View File

@@ -2,10 +2,19 @@ import { Module } from '@nestjs/common';
import { AuthService } from './AuthService';
import { AuthController } from './AuthController';
import { AuthProviders } from './AuthProviders';
import { AuthenticationGuard } from './AuthenticationGuard';
import { AuthorizationGuard } from './AuthorizationGuard';
import { AuthorizationService } from './AuthorizationService';
@Module({
controllers: [AuthController],
providers: [AuthService, ...AuthProviders],
exports: [AuthService],
providers: [
AuthService,
...AuthProviders,
AuthenticationGuard,
AuthorizationService,
AuthorizationGuard,
],
exports: [AuthService, AuthenticationGuard, AuthorizationService, AuthorizationGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { AuthenticationGuard } from './AuthenticationGuard';
function createExecutionContext(request: Record<string, unknown>) {
return {
switchToHttp: () => ({
getRequest: () => request,
}),
};
}
describe('AuthenticationGuard', () => {
it('attaches request.user.userId from session when missing', async () => {
const request: any = {};
const sessionPort = {
getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(request.user).toEqual({ userId: 'user-1' });
});
it('does not override request.user.userId if already present', async () => {
const request: any = { user: { userId: 'already-set' } };
const sessionPort = {
getCurrentSession: vi.fn(async () => ({ token: 't', user: { id: 'user-1' } })),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).not.toHaveBeenCalled();
expect(request.user).toEqual({ userId: 'already-set' });
});
it('leaves request.user undefined when no session exists', async () => {
const request: any = {};
const sessionPort = {
getCurrentSession: vi.fn(async () => null),
};
const guard = new AuthenticationGuard(sessionPort as any);
await expect(guard.canActivate(createExecutionContext(request) as any)).resolves.toBe(true);
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(request.user).toBeUndefined();
});
});

View File

@@ -0,0 +1,30 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { IDENTITY_SESSION_PORT_TOKEN } from './AuthProviders';
type AuthenticatedRequest = {
user?: { userId: string };
};
@Injectable()
export class AuthenticationGuard implements CanActivate {
constructor(
@Inject(IDENTITY_SESSION_PORT_TOKEN)
private readonly sessionPort: IdentitySessionPort,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (request.user?.userId) {
return true;
}
const session = await this.sessionPort.getCurrentSession();
if (session?.user?.id) {
request.user = { userId: session.user.id };
}
return true;
}
}

View File

@@ -0,0 +1,89 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { describe, expect, it, vi } from 'vitest';
import { AuthorizationGuard } from './AuthorizationGuard';
import { Public } from './Public';
import { RequireRoles } from './RequireRoles';
class DummyController {
@Public()
publicHandler(): void {}
protectedHandler(): void {}
@RequireRoles('admin')
adminHandler(): void {}
}
function createExecutionContext(options: { handler: Function; userId?: string }) {
const request = options.userId ? { user: { userId: options.userId } } : {};
return {
getHandler: () => options.handler,
getClass: () => DummyController,
switchToHttp: () => ({
getRequest: () => request,
}),
};
}
describe('AuthorizationGuard', () => {
it('allows public routes without a user session', () => {
const authorizationService = { getRolesForUser: vi.fn() };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.publicHandler,
});
expect(guard.canActivate(ctx as any)).toBe(true);
expect(authorizationService.getRolesForUser).not.toHaveBeenCalled();
});
it('denies non-public routes by default when not authenticated', () => {
const authorizationService = { getRolesForUser: vi.fn() };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.protectedHandler,
});
expect(() => guard.canActivate(ctx as any)).toThrow(UnauthorizedException);
});
it('allows non-public routes when authenticated', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue([]) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.protectedHandler,
userId: 'user-1',
});
expect(guard.canActivate(ctx as any)).toBe(true);
});
it('denies routes requiring roles when user does not have any required role', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['user']) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.adminHandler,
userId: 'user-1',
});
expect(() => guard.canActivate(ctx as any)).toThrow(ForbiddenException);
});
it('allows routes requiring roles when user has a required role', () => {
const authorizationService = { getRolesForUser: vi.fn().mockReturnValue(['admin']) };
const guard = new AuthorizationGuard(new Reflector(), authorizationService as any);
const ctx = createExecutionContext({
handler: DummyController.prototype.adminHandler,
userId: 'user-1',
});
expect(guard.canActivate(ctx as any)).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthorizationService } from './AuthorizationService';
import { PUBLIC_ROUTE_METADATA_KEY } from './Public';
import { REQUIRE_AUTHENTICATED_USER_METADATA_KEY } from './RequireAuthenticatedUser';
import { REQUIRE_ROLES_METADATA_KEY, RequireRolesMetadata } from './RequireRoles';
type AuthenticatedRequest = {
user?: { userId: string };
};
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly authorizationService: AuthorizationService,
) {}
canActivate(context: ExecutionContext): boolean {
const handler = context.getHandler();
const controllerClass = context.getClass();
const isPublic =
this.reflector.getAllAndOverride<{ public: true } | undefined>(
PUBLIC_ROUTE_METADATA_KEY,
[handler, controllerClass],
)?.public ?? false;
if (isPublic) {
return true;
}
const requiresAuth =
this.reflector.getAllAndOverride<{ required: true } | undefined>(
REQUIRE_AUTHENTICATED_USER_METADATA_KEY,
[handler, controllerClass],
)?.required ?? false;
const rolesMetadata =
this.reflector.getAllAndOverride<RequireRolesMetadata | undefined>(
REQUIRE_ROLES_METADATA_KEY,
[handler, controllerClass],
) ?? null;
// Protect all endpoints by default:
// - if a route is not marked public, it requires an authenticated user.
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const userId = request.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
// If the route explicitly requires auth, that's already satisfied by userId.
void requiresAuth;
if (rolesMetadata && rolesMetadata.anyOf.length > 0) {
const userRoles = this.authorizationService.getRolesForUser(userId);
const hasAnyRole = rolesMetadata.anyOf.some((r) => userRoles.includes(r));
if (!hasAnyRole) {
throw new ForbiddenException('Forbidden');
}
}
return true;
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
export type SystemRole = string;
@Injectable()
export class AuthorizationService {
private cache:
| {
rolesByUserId: Readonly<Record<string, readonly SystemRole[]>>;
expiresAtMs: number;
}
| null = null;
getRolesForUser(userId: string): readonly SystemRole[] {
const now = Date.now();
if (this.cache && now < this.cache.expiresAtMs) {
return this.cache.rolesByUserId[userId] ?? [];
}
const cacheMs = parseCacheMs(process.env.GRIDPILOT_AUTHZ_CACHE_MS);
const rolesByUserId = parseUserRolesJson(process.env.GRIDPILOT_USER_ROLES_JSON);
this.cache = {
rolesByUserId,
expiresAtMs: now + cacheMs,
};
return rolesByUserId[userId] ?? [];
}
}
function parseCacheMs(raw: string | undefined): number {
if (!raw) {
return 5_000;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) {
return 5_000;
}
return parsed;
}
function parseUserRolesJson(raw: string | undefined): Readonly<Record<string, readonly SystemRole[]>> {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
const record = parsed as Record<string, unknown>;
const normalized: Record<string, readonly SystemRole[]> = {};
for (const [userId, roles] of Object.entries(record)) {
if (!userId || !Array.isArray(roles)) {
continue;
}
const cleaned = roles
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
if (cleaned.length > 0) {
normalized[userId] = cleaned;
}
}
return normalized;
} catch {
return {};
}
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const PUBLIC_ROUTE_METADATA_KEY = 'gridpilot:publicRoute';
export type PublicRouteMetadata = {
readonly public: true;
};
export function Public(): MethodDecorator & ClassDecorator {
return SetMetadata(PUBLIC_ROUTE_METADATA_KEY, {
public: true,
} satisfies PublicRouteMetadata);
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_AUTHENTICATED_USER_METADATA_KEY = 'gridpilot:requireAuthenticatedUser';
export type RequireAuthenticatedUserMetadata = {
readonly required: true;
};
export function RequireAuthenticatedUser(): MethodDecorator & ClassDecorator {
return SetMetadata(REQUIRE_AUTHENTICATED_USER_METADATA_KEY, {
required: true,
} satisfies RequireAuthenticatedUserMetadata);
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_ROLES_METADATA_KEY = 'gridpilot:requireRoles';
export type RequireRolesMetadata = {
readonly anyOf: readonly string[];
};
export function RequireRoles(...anyOf: readonly string[]): MethodDecorator & ClassDecorator {
return SetMetadata(REQUIRE_ROLES_METADATA_KEY, {
anyOf,
} satisfies RequireRolesMetadata);
}

View File

@@ -1,6 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { DashboardController } from './DashboardController';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import { DashboardService } from './DashboardService';
describe('DashboardController', () => {
let controller: DashboardController;
@@ -34,10 +45,93 @@ describe('DashboardController', () => {
};
mockService.getDashboardOverview.mockResolvedValue(overview);
const result = await controller.getDashboardOverview(driverId);
const result = await controller.getDashboardOverview(driverId, { user: { userId: driverId } });
expect(mockService.getDashboardOverview).toHaveBeenCalledWith(driverId);
expect(result).toEqual(overview);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [DashboardController],
providers: [
{
provide: DashboardService,
useValue: {
getDashboardOverview: vi.fn(async () => ({
currentDriver: null,
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 0,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: { notificationCount: 0, items: [] },
friends: [],
})),
},
},
],
})
.overrideProvider(DashboardController)
.useFactory({
factory: (dashboardService: DashboardService) => new DashboardController(dashboardService),
inject: [DashboardService],
})
.compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('denies endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(401);
});
it('allows endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/dashboard/overview?driverId=d1').expect(200);
});
});
});

View File

@@ -1,18 +1,29 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Controller, Get, Query, Req, UnauthorizedException, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { DashboardService } from './DashboardService';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('dashboard')
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
constructor(@Inject(DashboardService) private readonly dashboardService: DashboardService) {}
@Get('overview')
@ApiOperation({ summary: 'Get dashboard overview' })
@ApiQuery({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Dashboard overview', type: DashboardOverviewDTO })
async getDashboardOverview(@Query('driverId') driverId: string): Promise<DashboardOverviewDTO> {
return this.dashboardService.getDashboardOverview(driverId);
async getDashboardOverview(
@Query('driverId') _driverId: string,
@Req() req: AuthenticatedRequest,
): Promise<DashboardOverviewDTO> {
const userId = req.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
return this.dashboardService.getDashboardOverview(userId);
}
}

View File

@@ -1,8 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { DriverController } from './DriverController';
import { DriverService } from './DriverService';
import type { Request } from 'express';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
interface AuthenticatedRequest extends Request {
user?: { userId: string };
@@ -160,4 +169,75 @@ describe('DriverController', () => {
expect(result).toEqual(updated);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [DriverController],
providers: [
{
provide: DriverService,
useValue: {
getDriversLeaderboard: vi.fn(async () => ({ drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 })),
getCurrentDriver: vi.fn(async () => ({ id: 'd1' })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/drivers/leaderboard').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/drivers/current').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/drivers/current').expect(200);
});
});
});

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { DriverService } from './DriverService';
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
@@ -20,6 +21,7 @@ import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
export class DriverController {
constructor(@Inject(DriverService) private readonly driverService: DriverService) {}
@Public()
@Get('leaderboard')
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardDTO })
@@ -27,6 +29,7 @@ export class DriverController {
return await this.driverService.getDriversLeaderboard();
}
@Public()
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDTO })
@@ -61,6 +64,7 @@ export class DriverController {
return await this.driverService.completeOnboarding(userId, input);
}
@Public()
@Get(':driverId/races/:raceId/registration-status')
@ApiOperation({ summary: 'Get driver registration status for a specific race' })
@ApiResponse({ status: 200, description: 'Driver registration status', type: DriverRegistrationStatusDTO })
@@ -71,6 +75,7 @@ export class DriverController {
return await this.driverService.getDriverRegistrationStatus({ driverId, raceId });
}
@Public()
@Get(':driverId')
@ApiOperation({ summary: 'Get driver by ID' })
@ApiResponse({ status: 200, description: 'Driver data', type: GetDriverOutputDTO })
@@ -79,6 +84,7 @@ export class DriverController {
return await this.driverService.getDriver(driverId);
}
@Public()
@Get(':driverId/profile')
@ApiOperation({ summary: 'Get driver profile with full details' })
@ApiResponse({ status: 200, description: 'Driver profile data', type: GetDriverProfileOutputDTO })

View File

@@ -1,8 +1,10 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/Public';
import { HelloService } from './HelloService';
@Public()
@Controller()
export class HelloController {
constructor(private readonly helloService: HelloService) {}

View File

@@ -1,7 +1,16 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('LeagueController', () => {
let controller: LeagueController;
@@ -55,4 +64,75 @@ describe('LeagueController', () => {
expect(result).toEqual(mockResult);
expect(leagueService.getLeagueStandings).toHaveBeenCalledWith('league-1');
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [LeagueController],
providers: [
{
provide: LeagueService,
useValue: {
getTotalLeagues: vi.fn(async () => ({ totalLeagues: 0 })),
getLeagueAdmin: vi.fn(async () => ({}) ),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/leagues/total-leagues').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/leagues/l1/admin').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/leagues/l1/admin').expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
@@ -40,6 +41,7 @@ import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO';
export class LeagueController {
constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {}
@Public()
@Get('all-with-capacity')
@ApiOperation({ summary: 'Get all leagues with their capacity information' })
@ApiResponse({ status: 200, description: 'List of leagues with capacity', type: AllLeaguesWithCapacityDTO })
@@ -47,6 +49,7 @@ export class LeagueController {
return this.leagueService.getAllLeaguesWithCapacity();
}
@Public()
@Get('total-leagues')
@ApiOperation({ summary: 'Get the total number of leagues' })
@ApiResponse({ status: 200, description: 'Total number of leagues', type: TotalLeaguesDTO })
@@ -126,6 +129,7 @@ export class LeagueController {
return this.leagueService.updateLeagueMemberRole({ leagueId, performerDriverId, targetDriverId, newRole: input.newRole });
}
@Public()
@Get(':leagueId/owner-summary/:ownerId')
@ApiOperation({ summary: 'Get owner summary for a league' })
@ApiResponse({ status: 200, description: 'League owner summary', type: LeagueOwnerSummaryDTO })
@@ -187,6 +191,7 @@ export class LeagueController {
};
}
@Public()
@Get(':leagueId/seasons')
@ApiOperation({ summary: 'Get seasons for a league' })
@ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] })
@@ -195,6 +200,7 @@ export class LeagueController {
return this.leagueService.getLeagueSeasons(query);
}
@Public()
@Get(':leagueId/memberships')
@ApiOperation({ summary: 'Get league memberships' })
@ApiResponse({ status: 200, description: 'List of league members', type: LeagueMembershipsDTO })
@@ -202,6 +208,7 @@ export class LeagueController {
return this.leagueService.getLeagueMemberships(leagueId);
}
@Public()
@Get(':leagueId/standings')
@ApiOperation({ summary: 'Get league standings' })
@ApiResponse({ status: 200, description: 'League standings', type: LeagueStandingsDTO })
@@ -209,6 +216,7 @@ export class LeagueController {
return this.leagueService.getLeagueStandings(leagueId);
}
@Public()
@Get(':leagueId/schedule')
@ApiOperation({ summary: 'Get league schedule' })
@ApiResponse({ status: 200, description: 'League schedule', type: LeagueScheduleDTO })
@@ -216,6 +224,7 @@ export class LeagueController {
return this.leagueService.getLeagueSchedule(leagueId);
}
@Public()
@Get(':leagueId/stats')
@ApiOperation({ summary: 'Get league stats' })
@ApiResponse({ status: 200, description: 'League stats', type: LeagueStatsDTO })
@@ -238,6 +247,7 @@ export class LeagueController {
return this.leagueService.createLeague(input);
}
@Public()
@Get('scoring-presets')
@ApiOperation({ summary: 'Get league scoring presets' })
@ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO })
@@ -245,6 +255,7 @@ export class LeagueController {
return this.leagueService.listLeagueScoringPresets();
}
@Public()
@Get(':leagueId/scoring-config')
@ApiOperation({ summary: 'Get league scoring config' })
@ApiResponse({ status: 200, description: 'League scoring config' })
@@ -266,6 +277,7 @@ export class LeagueController {
return this.leagueService.transferLeagueOwnership(leagueId, body.currentOwnerId, body.newOwnerId);
}
@Public()
@Get('seasons/:seasonId/sponsorships')
@ApiOperation({ summary: 'Get season sponsorships' })
@ApiResponse({ status: 200, description: 'Season sponsorships', type: GetSeasonSponsorshipsOutputDTO })
@@ -273,6 +285,7 @@ export class LeagueController {
return this.leagueService.getSeasonSponsorships(seasonId);
}
@Public()
@Get(':leagueId/races')
@ApiOperation({ summary: 'Get league races' })
@ApiResponse({ status: 200, description: 'League races', type: GetLeagueRacesOutputDTO })

View File

@@ -1,8 +1,17 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { MediaController } from './MediaController';
import { MediaService } from './MediaService';
import type { Response } from 'express';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
@@ -248,4 +257,75 @@ describe('MediaController', () => {
expect(res.json).toHaveBeenCalledWith(dto);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [MediaController],
providers: [
{
provide: MediaService,
useValue: {
getMedia: vi.fn(async () => ({ id: 'm1' })),
deleteMedia: vi.fn(async () => ({ success: true })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/media/m1').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).delete('/media/m1').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).delete('/media/m1').expect(200);
});
});
});

View File

@@ -2,6 +2,7 @@ import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseIn
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { Public } from '../auth/Public';
import { MediaService } from './MediaService';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
@@ -60,6 +61,7 @@ export class MediaController {
}
}
@Public()
@Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' })
@ApiParam({ name: 'mediaId', description: 'Media ID' })
@@ -90,6 +92,7 @@ export class MediaController {
res.status(HttpStatus.OK).json(dto);
}
@Public()
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })

View File

@@ -1,7 +1,16 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { PaymentsController } from './PaymentsController';
import { PaymentsService } from './PaymentsService';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import {
GetPaymentsQuery,
CreatePaymentInput,
@@ -366,4 +375,107 @@ describe('PaymentsController', () => {
expect(response).toEqual(result);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [PaymentsController],
providers: [
{
provide: PaymentsService,
useValue: {
getPayments: vi.fn(async () => ({ payments: [] })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('denies endpoint when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/payments').expect(401);
});
it('returns 403 when authenticated but missing required role', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/payments').expect(403);
});
it('returns 404 when role is satisfied but capability is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'disabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(404);
});
it('allows access when role is satisfied and capability is enabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'payments.admin': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/payments').expect(200);
});
});
});

View File

@@ -1,14 +1,20 @@
import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { RequireCapability } from '../policy/RequireCapability';
import { PaymentsService } from './PaymentsService';
import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dtos/PaymentsDto';
@ApiTags('payments')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@Controller('payments')
export class PaymentsController {
constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {}
@Get()
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get payments based on filters' })
@ApiResponse({ status: 200, description: 'List of payments', type: GetPaymentsOutput })
async getPayments(@Query() query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
@@ -16,6 +22,7 @@ export class PaymentsController {
}
@Post()
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new payment' })
@ApiResponse({ status: 201, description: 'Payment created', type: CreatePaymentOutput })
@@ -24,6 +31,7 @@ export class PaymentsController {
}
@Patch('status')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Update the status of a payment' })
@ApiResponse({ status: 200, description: 'Payment status updated', type: UpdatePaymentStatusOutput })
async updatePaymentStatus(@Body() input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
@@ -31,6 +39,7 @@ export class PaymentsController {
}
@Get('membership-fees')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get membership fees and member payments' })
@ApiResponse({ status: 200, description: 'Membership fee configuration and member payments', type: GetMembershipFeesOutput })
async getMembershipFees(@Query() query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
@@ -38,6 +47,7 @@ export class PaymentsController {
}
@Post('membership-fees')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create or update membership fee configuration' })
@ApiResponse({ status: 201, description: 'Membership fee configuration created or updated', type: UpsertMembershipFeeOutput })
@@ -46,12 +56,14 @@ export class PaymentsController {
}
@Patch('membership-fees/member-payment')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Record or update a member payment' })
@ApiResponse({ status: 200, description: 'Member payment recorded or updated', type: UpdateMemberPaymentOutput })
async updateMemberPayment(@Body() input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
return this.paymentsService.updateMemberPayment(input);
}
@Get('prizes')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get prizes for a league or season' })
@ApiResponse({ status: 200, description: 'List of prizes', type: GetPrizesOutput })
async getPrizes(@Query() query: GetPrizesQuery): Promise<GetPrizesOutput> {
@@ -59,6 +71,7 @@ export class PaymentsController {
}
@Post('prizes')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new prize' })
@ApiResponse({ status: 201, description: 'Prize created', type: CreatePrizeOutput })
@@ -67,6 +80,7 @@ export class PaymentsController {
}
@Patch('prizes/award')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Award a prize to a driver' })
@ApiResponse({ status: 200, description: 'Prize awarded', type: AwardPrizeOutput })
async awardPrize(@Body() input: AwardPrizeInput): Promise<AwardPrizeOutput> {
@@ -74,12 +88,14 @@ export class PaymentsController {
}
@Delete('prizes')
@RequireCapability('payments.admin', 'mutate')
@ApiOperation({ summary: 'Delete a prize' })
@ApiResponse({ status: 200, description: 'Prize deleted', type: DeletePrizeOutput })
async deletePrize(@Query() query: DeletePrizeInput): Promise<DeletePrizeOutput> {
return this.paymentsService.deletePrize(query);
}
@Get('wallets')
@RequireCapability('payments.admin', 'view')
@ApiOperation({ summary: 'Get wallet information and transactions' })
@ApiResponse({ status: 200, description: 'Wallet and transaction data', type: GetWalletOutput })
async getWallet(@Query() query: GetWalletQuery): Promise<GetWalletOutput> {
@@ -87,6 +103,7 @@ export class PaymentsController {
}
@Post('wallets/transactions')
@RequireCapability('payments.admin', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Process a wallet transaction (deposit or withdrawal)' })
@ApiResponse({ status: 201, description: 'Wallet transaction processed', type: ProcessWalletTransactionOutput })

View File

@@ -0,0 +1,91 @@
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PolicyService, ActionType, FeatureState } from './PolicyService';
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
@Injectable()
export class FeatureAvailabilityGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly policyService: PolicyService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const controllerClass = context.getClass();
const metadata =
this.reflector.getAllAndOverride<FeatureAvailabilityMetadata | undefined>(
FEATURE_AVAILABILITY_METADATA_KEY,
[handler, controllerClass],
) ?? null;
if (!metadata) {
return true;
}
const snapshot = await this.policyService.getSnapshot();
const decision = evaluate(snapshot, metadata);
if (decision.allow) {
return true;
}
if (decision.reason === 'maintenance') {
throw new ServiceUnavailableException('Service temporarily unavailable');
}
throw new NotFoundException('Not Found');
}
}
function evaluate(
snapshot: Awaited<ReturnType<PolicyService['getSnapshot']>>,
metadata: FeatureAvailabilityMetadata,
): Evaluation {
if (snapshot.operationalMode === 'maintenance') {
const allowlist = metadata.actionType === 'mutate'
? snapshot.maintenanceAllowlist.mutate
: snapshot.maintenanceAllowlist.view;
if (!allowlist.includes(metadata.capabilityKey)) {
return { allow: false, reason: 'maintenance' };
}
}
const state = snapshot.capabilities[metadata.capabilityKey] ?? 'hidden';
if (state === 'enabled') {
return { allow: true };
}
// Coming soon is treated as "not found" on the public API for now (no disclosure).
if (state === 'coming_soon') {
return { allow: false, reason: 'coming_soon' };
}
if (state === 'disabled' || state === 'hidden') {
return { allow: false, reason: state };
}
return { allow: false, reason: 'not_configured' };
}
export function inferActionTypeFromHttpMethod(method: string): ActionType {
switch (method.toUpperCase()) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
return 'view';
default:
return 'mutate';
}
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../auth/Public';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { PolicyService, PolicySnapshot } from './PolicyService';
@ApiTags('policy')
@Public()
@Controller('policy')
export class PolicyController {
constructor(private readonly policyService: PolicyService) {}
@Get('snapshot')
@ApiOperation({ summary: 'Get current feature availability policy snapshot (read-only)' })
@ApiResponse({ status: 200, description: 'Policy snapshot', type: Object })
async getSnapshot(): Promise<PolicySnapshot> {
return await this.policyService.getSnapshot();
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PolicyController } from './PolicyController';
import { PolicyService } from './PolicyService';
import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard';
@Module({
controllers: [PolicyController],
providers: [PolicyService, FeatureAvailabilityGuard],
exports: [PolicyService, FeatureAvailabilityGuard],
})
export class PolicyModule {}

View File

@@ -0,0 +1,229 @@
import { Injectable } from '@nestjs/common';
import { readFile } from 'node:fs/promises';
export type OperationalMode = 'normal' | 'maintenance' | 'test';
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
export type ActionType = 'view' | 'mutate';
export type PolicySnapshot = {
readonly policyVersion: number;
readonly operationalMode: OperationalMode;
readonly maintenanceAllowlist: {
readonly view: readonly string[];
readonly mutate: readonly string[];
};
readonly capabilities: Readonly<Record<string, FeatureState>>;
readonly loadedFrom: 'env' | 'file' | 'defaults';
readonly loadedAtIso: string;
};
const DEFAULT_POLICY_VERSION = 1;
const DEFAULT_CACHE_MS = 5_000;
type RawPolicySnapshot = Partial<{
policyVersion: number;
operationalMode: string;
maintenanceAllowlist: Partial<{
view: unknown;
mutate: unknown;
}>;
capabilities: unknown;
}>;
@Injectable()
export class PolicyService {
private cache:
| {
snapshot: PolicySnapshot;
expiresAtMs: number;
}
| null = null;
async getSnapshot(): Promise<PolicySnapshot> {
const now = Date.now();
if (this.cache && now < this.cache.expiresAtMs) {
return this.cache.snapshot;
}
const cacheMs = parseCacheMs(process.env.GRIDPILOT_POLICY_CACHE_MS);
const loadedAtIso = new Date(now).toISOString();
const { raw, loadedFrom } = await this.loadRawSnapshot();
const snapshot = normalizeSnapshot(raw, loadedFrom, loadedAtIso);
this.cache = {
snapshot,
expiresAtMs: now + cacheMs,
};
return snapshot;
}
private async loadRawSnapshot(): Promise<{
raw: RawPolicySnapshot;
loadedFrom: PolicySnapshot['loadedFrom'];
}> {
const policyPath = process.env.GRIDPILOT_POLICY_PATH;
if (policyPath) {
const rawJson = await readFile(policyPath, 'utf8');
return {
raw: JSON.parse(rawJson) as RawPolicySnapshot,
loadedFrom: 'file',
};
}
const anyEnvConfigured =
Boolean(process.env.GRIDPILOT_OPERATIONAL_MODE) ||
Boolean(process.env.GRIDPILOT_FEATURES_JSON) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW) ||
Boolean(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
const raw: RawPolicySnapshot = {};
const operationalMode = process.env.GRIDPILOT_OPERATIONAL_MODE;
if (operationalMode) {
raw.operationalMode = operationalMode;
}
const capabilities = parseFeaturesJson(process.env.GRIDPILOT_FEATURES_JSON);
if (capabilities) {
raw.capabilities = capabilities;
}
const maintenanceAllowView = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW);
const maintenanceAllowMutate = parseCsvList(process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE);
if (maintenanceAllowView || maintenanceAllowMutate) {
raw.maintenanceAllowlist = {
...(maintenanceAllowView ? { view: maintenanceAllowView } : {}),
...(maintenanceAllowMutate ? { mutate: maintenanceAllowMutate } : {}),
};
}
return {
raw,
loadedFrom: anyEnvConfigured ? 'env' : 'defaults',
};
}
}
function normalizeSnapshot(
raw: RawPolicySnapshot,
loadedFrom: PolicySnapshot['loadedFrom'],
loadedAtIso: string,
): PolicySnapshot {
const operationalMode = parseOperationalMode(raw.operationalMode);
const maintenanceAllowlistView = normalizeStringArray(raw.maintenanceAllowlist?.view);
const maintenanceAllowlistMutate = normalizeStringArray(raw.maintenanceAllowlist?.mutate);
const capabilities = normalizeCapabilities(raw.capabilities);
return {
policyVersion:
typeof raw.policyVersion === 'number' ? raw.policyVersion : DEFAULT_POLICY_VERSION,
operationalMode,
maintenanceAllowlist: {
view: maintenanceAllowlistView,
mutate: maintenanceAllowlistMutate,
},
capabilities,
loadedFrom,
loadedAtIso,
};
}
function parseCacheMs(cacheMsRaw: string | undefined): number {
if (!cacheMsRaw) {
return DEFAULT_CACHE_MS;
}
const parsed = Number(cacheMsRaw);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_CACHE_MS;
}
return parsed;
}
function parseOperationalMode(raw: string | undefined): OperationalMode {
switch (raw) {
case 'normal':
case 'maintenance':
case 'test':
return raw;
default:
return 'normal';
}
}
function normalizeCapabilities(input: unknown): Readonly<Record<string, FeatureState>> {
if (!input || typeof input !== 'object') {
return {};
}
const record = input as Record<string, unknown>;
const normalized: Record<string, FeatureState> = {};
for (const [key, value] of Object.entries(record)) {
if (!key) {
continue;
}
const state = parseFeatureState(value);
if (state) {
normalized[key] = state;
}
}
return normalized;
}
function parseFeatureState(value: unknown): FeatureState | null {
if (typeof value !== 'string') {
return null;
}
switch (value) {
case 'enabled':
case 'disabled':
case 'coming_soon':
case 'hidden':
return value;
default:
return null;
}
}
function parseFeaturesJson(raw: string | undefined): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function parseCsvList(raw: string | undefined): readonly string[] | undefined {
if (!raw) {
return undefined;
}
const items = raw
.split(',')
.map((v) => v.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
function normalizeStringArray(value: unknown): readonly string[] {
if (!Array.isArray(value)) {
return [];
}
const normalized = value
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter(Boolean);
return normalized;
}

View File

@@ -0,0 +1,24 @@
import { SetMetadata } from '@nestjs/common';
import type { ActionType } from './PolicyService';
export const FEATURE_AVAILABILITY_METADATA_KEY = 'gridpilot:featureAvailability';
export type FeatureAvailabilityMetadata = {
readonly capabilityKey: string;
readonly actionType: ActionType;
};
/**
* Attach feature availability metadata to a controller or route handler.
*
* The backend Guard is authoritative enforcement (not UX).
*/
export function RequireCapability(
capabilityKey: string,
actionType: ActionType,
): MethodDecorator & ClassDecorator {
return SetMetadata(FEATURE_AVAILABILITY_METADATA_KEY, {
capabilityKey,
actionType,
} satisfies FeatureAvailabilityMetadata);
}

View File

@@ -31,10 +31,12 @@ describe('ProtestsController', () => {
const successDto = (dto: ReviewProtestResponseDTO): ReviewProtestResponseDTO => dto;
describe('reviewProtest', () => {
const stewardUserId = 'steward-1';
const req = { user: { userId: stewardUserId } };
it('should call service and not throw on success', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -43,20 +45,19 @@ describe('ProtestsController', () => {
successDto({
success: true,
protestId,
stewardId: body.stewardId,
stewardId: stewardUserId,
decision: body.decision,
}),
);
await controller.reviewProtest(protestId, body);
await controller.reviewProtest(protestId, body, req);
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, ...body });
expect(reviewProtestMock).toHaveBeenCalledWith({ protestId, stewardId: stewardUserId, ...body });
});
it('should throw NotFoundException when protest is not found', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -69,13 +70,12 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(NotFoundException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(NotFoundException);
});
it('should throw ForbiddenException when steward is not league admin', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -88,13 +88,12 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(ForbiddenException);
});
it('should throw InternalServerErrorException for unexpected error codes', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = {
stewardId: 'steward-1',
const body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'> = {
decision: 'uphold',
decisionNotes: 'Reason',
};
@@ -107,7 +106,7 @@ describe('ProtestsController', () => {
}),
);
await expect(controller.reviewProtest(protestId, body)).rejects.toBeInstanceOf(InternalServerErrorException);
await expect(controller.reviewProtest(protestId, body, req)).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
});

View File

@@ -1,9 +1,13 @@
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post, Req, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
type AuthenticatedRequest = {
user?: { userId: string };
};
@ApiTags('protests')
@Controller('protests')
export class ProtestsController {
@@ -16,9 +20,19 @@ export class ProtestsController {
@ApiResponse({ status: 200, description: 'Protest reviewed successfully' })
async reviewProtest(
@Param('protestId') protestId: string,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId'>,
@Body() body: Omit<ReviewProtestCommandDTO, 'protestId' | 'stewardId'>,
@Req() req: AuthenticatedRequest,
): Promise<void> {
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({ protestId, ...body });
const userId = req.user?.userId;
if (!userId) {
throw new UnauthorizedException('Unauthorized');
}
const result: ReviewProtestResponseDTO = await this.protestsService.reviewProtest({
protestId,
stewardId: userId,
...body,
});
if (!result.success) {
switch (result.errorCode) {

View File

@@ -1,7 +1,16 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { RaceController } from './RaceController';
import { RaceService } from './RaceService';
import { vi, Mocked } from 'vitest';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('RaceController', () => {
let controller: RaceController;
@@ -70,4 +79,75 @@ describe('RaceController', () => {
expect(result).toEqual(mockPresenter.viewModel);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [RaceController],
providers: [
{
provide: RaceService,
useValue: {
getAllRaces: vi.fn(async () => ({ viewModel: { races: [], filters: { statuses: [], leagues: [] } } })),
registerForRace: vi.fn(async () => ({ viewModel: { success: true } })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() read without a session', async () => {
await request(app.getHttpServer()).get('/races/all').expect(200);
});
it('denies mutation by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(401);
});
it('allows mutation when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).post('/races/r1/register').send({}).expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query, Inject } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
@@ -24,6 +25,7 @@ import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
export class RaceController {
constructor(@Inject(RaceService) private readonly raceService: RaceService) {}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all races' })
@ApiResponse({ status: 200, description: 'List of all races', type: AllRacesPageDTO })
@@ -32,6 +34,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('total-races')
@ApiOperation({ summary: 'Get the total number of races' })
@ApiResponse({ status: 200, description: 'Total number of races', type: RaceStatsDTO })
@@ -40,6 +43,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('page-data')
@ApiOperation({ summary: 'Get races page data' })
@ApiQuery({ name: 'leagueId', description: 'League ID' })
@@ -49,6 +53,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('all/page-data')
@ApiOperation({ summary: 'Get all races page data' })
@ApiResponse({ status: 200, description: 'All races page data', type: AllRacesPageDTO })
@@ -57,6 +62,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get(':raceId')
@ApiOperation({ summary: 'Get race detail' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -64,12 +70,14 @@ export class RaceController {
@ApiResponse({ status: 200, description: 'Race detail', type: RaceDetailDTO })
async getRaceDetail(
@Param('raceId') raceId: string,
@Query('driverId') driverId: string,
@Query('driverId') driverId?: string,
): Promise<RaceDetailDTO> {
const presenter = await this.raceService.getRaceDetail({ raceId, driverId });
const params = driverId ? { raceId, driverId } : { raceId };
const presenter = await this.raceService.getRaceDetail(params);
return await presenter.viewModel;
}
@Public()
@Get(':raceId/results')
@ApiOperation({ summary: 'Get race results detail' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -79,6 +87,7 @@ export class RaceController {
return await presenter.viewModel;
}
@Public()
@Get(':raceId/sof')
@ApiOperation({ summary: 'Get race with strength of field' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -88,6 +97,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get(':raceId/protests')
@ApiOperation({ summary: 'Get race protests' })
@ApiParam({ name: 'raceId', description: 'Race ID' })
@@ -97,6 +107,7 @@ export class RaceController {
return presenter.viewModel;
}
@Public()
@Get('reference/penalty-types')
@ApiOperation({ summary: 'Get allowed penalty types and semantics' })
@ApiResponse({ status: 200, description: 'Penalty types reference', type: PenaltyTypesReferenceDTO })
@@ -104,6 +115,7 @@ export class RaceController {
return this.raceService.getPenaltyTypesReference();
}
@Public()
@Get(':raceId/penalties')
@ApiOperation({ summary: 'Get race penalties' })
@ApiParam({ name: 'raceId', description: 'Race ID' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class GetRaceDetailParamsDTO {
@ApiProperty()
@@ -7,8 +7,8 @@ export class GetRaceDetailParamsDTO {
@IsNotEmpty()
raceId!: string;
@ApiProperty()
@ApiPropertyOptional()
@IsString()
@IsNotEmpty()
driverId!: string;
@IsOptional()
driverId?: string;
}

View File

@@ -1,14 +1,23 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { SponsorController } from './SponsorController';
import { SponsorService } from './SponsorService';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('SponsorController', () => {
let controller: SponsorController;
let sponsorService: ReturnType<typeof vi.mocked<SponsorService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
const moduleBuilder = Test.createTestingModule({
controllers: [SponsorController],
providers: [
{
@@ -31,7 +40,15 @@ describe('SponsorController', () => {
},
},
],
}).compile();
})
.overrideGuard(AuthenticationGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) })
.overrideGuard(AuthorizationGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) })
.overrideGuard(FeatureAvailabilityGuard)
.useValue({ canActivate: vi.fn().mockResolvedValue(true) });
const module: TestingModule = await moduleBuilder.compile();
controller = module.get<SponsorController>(SponsorController);
sponsorService = vi.mocked(module.get(SponsorService));
@@ -309,4 +326,112 @@ describe('SponsorController', () => {
expect(sponsorService.updateSponsorSettings).toHaveBeenCalledWith(sponsorId, input);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'sponsors.portal': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [SponsorController],
providers: [
{
provide: SponsorService,
useValue: {
getEntitySponsorshipPricing: vi.fn(async () => ({ entityType: 'season', entityId: 's1', pricing: [] })),
getSponsors: vi.fn(async () => ({ sponsors: [] })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/sponsors/pricing').expect(200);
});
it('denies protected endpoint when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/sponsors').expect(401);
});
it('returns 403 when authenticated but missing required role', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['user']);
await request(app.getHttpServer()).get('/sponsors').expect(403);
});
it('returns 404 when role is satisfied but capability is disabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'sponsors.portal': 'disabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/sponsors').expect(404);
});
it('allows access when role is satisfied and capability is enabled', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
vi.mocked(authorizationService.getRolesForUser).mockReturnValueOnce(['admin']);
vi.mocked(policyService.getSnapshot).mockResolvedValueOnce({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: { 'sponsors.portal': 'enabled' },
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
});
await request(app.getHttpServer()).get('/sponsors').expect(200);
});
});
});

View File

@@ -1,5 +1,9 @@
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { RequireCapability } from '../policy/RequireCapability';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
@@ -31,6 +35,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us
export class SponsorController {
constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {}
@Public()
@Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
@ApiResponse({
@@ -43,6 +48,9 @@ export class SponsorController {
}
@Get()
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get all sponsors' })
@ApiResponse({
status: 200,
@@ -54,6 +62,9 @@ export class SponsorController {
}
@Post()
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new sponsor' })
@ApiResponse({
@@ -66,6 +77,9 @@ export class SponsorController {
}
@Get('dashboard/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor dashboard metrics and sponsored leagues' })
@ApiResponse({
status: 200,
@@ -82,6 +96,9 @@ export class SponsorController {
}
@Get(':sponsorId/sponsorships')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({
summary: 'Get all sponsorships for a given sponsor',
})
@@ -99,19 +116,10 @@ export class SponsorController {
} as GetSponsorSponsorshipsQueryParamsDTO);
}
@Get(':sponsorId')
@ApiOperation({ summary: 'Get a sponsor by ID' })
@ApiResponse({
status: 200,
description: 'Sponsor data',
type: GetSponsorOutputDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO> {
return await this.sponsorService.getSponsor(sponsorId);
}
@Get('requests')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get pending sponsorship requests' })
@ApiResponse({
status: 200,
@@ -129,7 +137,25 @@ export class SponsorController {
);
}
@Get(':sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get a sponsor by ID' })
@ApiResponse({
status: 200,
description: 'Sponsor data',
type: GetSponsorOutputDTO,
})
@ApiResponse({ status: 404, description: 'Sponsor not found' })
async getSponsor(@Param('sponsorId') sponsorId: string): Promise<GetSponsorOutputDTO> {
return await this.sponsorService.getSponsor(sponsorId);
}
@Post('requests/:requestId/accept')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@@ -146,6 +172,9 @@ export class SponsorController {
}
@Post('requests/:requestId/reject')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@@ -163,6 +192,9 @@ export class SponsorController {
}
@Get('billing/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor billing information' })
@ApiResponse({ status: 200, description: 'Sponsor billing data', type: Object })
async getSponsorBilling(
@@ -176,6 +208,9 @@ export class SponsorController {
}
@Get('leagues/available')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get available leagues for sponsorship' })
@ApiResponse({
status: 200,
@@ -188,6 +223,9 @@ export class SponsorController {
}
@Get('leagues/:leagueId/detail')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get detailed league information for sponsors' })
@ApiResponse({ status: 200, description: 'League detail data', type: Object })
async getLeagueDetail(
@@ -202,6 +240,9 @@ export class SponsorController {
}
@Get('settings/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'view')
@ApiOperation({ summary: 'Get sponsor settings' })
@ApiResponse({ status: 200, description: 'Sponsor settings', type: Object })
async getSponsorSettings(
@@ -216,6 +257,9 @@ export class SponsorController {
}
@Put('settings/:sponsorId')
@RequireAuthenticatedUser()
@RequireRoles('admin')
@RequireCapability('sponsors.portal', 'mutate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update sponsor settings' })
@ApiResponse({ status: 200, description: 'Settings updated successfully' })

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/AuthModule';
import { PolicyModule } from '../policy/PolicyModule';
import { SponsorService } from './SponsorService';
import { SponsorController } from './SponsorController';
import { SponsorProviders } from './SponsorProviders';
@Module({
imports: [AuthModule, PolicyModule],
controllers: [SponsorController],
providers: SponsorProviders,
exports: [SponsorService],

View File

@@ -1,9 +1,18 @@
import 'reflect-metadata';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { vi } from 'vitest';
import { TeamController } from './TeamController';
import { TeamService } from './TeamService';
import { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO';
import { UpdateTeamInput } from './dtos/TeamDto';
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
describe('TeamController', () => {
let controller: TeamController;
@@ -146,4 +155,75 @@ describe('TeamController', () => {
expect(response).toEqual(result);
});
});
describe('auth guards (HTTP)', () => {
let app: any;
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
getCurrentSession: vi.fn(async () => null),
};
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
getRolesForUser: vi.fn(() => []),
};
const policyService: Pick<PolicyService, 'getSnapshot'> = {
getSnapshot: vi.fn(async (): Promise<PolicySnapshot> => ({
policyVersion: 1,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date(0).toISOString(),
})),
};
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [TeamController],
providers: [
{
provide: TeamService,
useValue: {
getAll: vi.fn(async () => ({ teams: [], totalCount: 0 })),
getJoinRequests: vi.fn(async () => ({ requests: [], pendingCount: 0, totalCount: 0 })),
},
},
],
}).compile();
app = module.createNestApplication();
const reflector = new Reflector();
app.useGlobalGuards(
new AuthenticationGuard(sessionPort as any),
new AuthorizationGuard(reflector, authorizationService as any),
new FeatureAvailabilityGuard(reflector, policyService as any),
);
await app.init();
});
afterEach(async () => {
await app?.close();
vi.clearAllMocks();
});
it('allows @Public() endpoint without a session', async () => {
await request(app.getHttpServer()).get('/teams/all').expect(200);
});
it('denies non-public endpoint by default when not authenticated (401)', async () => {
await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(401);
});
it('allows non-public endpoint when authenticated via session port', async () => {
vi.mocked(sessionPort.getCurrentSession).mockResolvedValueOnce({
token: 't',
user: { id: 'user-1' },
});
await request(app.getHttpServer()).get('/teams/t1/join-requests').expect(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { Public } from '../auth/Public';
type RequestWithUser = Record<string, unknown> & {
user?: {
@@ -23,6 +24,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO';
export class TeamController {
constructor(@Inject(TeamService) private readonly teamService: TeamService) {}
@Public()
@Get('all')
@ApiOperation({ summary: 'Get all teams' })
@ApiResponse({ status: 200, description: 'List of all teams', type: GetAllTeamsOutputDTO })
@@ -30,6 +32,7 @@ export class TeamController {
return await this.teamService.getAll();
}
@Public()
@Get(':teamId')
@ApiOperation({ summary: 'Get team details' })
@ApiResponse({ status: 200, description: 'Team details', type: GetTeamDetailsOutputDTO })
@@ -39,6 +42,7 @@ export class TeamController {
return await this.teamService.getDetails(teamId, userId);
}
@Public()
@Get(':teamId/members')
@ApiOperation({ summary: 'Get team members' })
@ApiResponse({ status: 200, description: 'Team members', type: GetTeamMembersOutputDTO })
@@ -69,6 +73,7 @@ export class TeamController {
return await this.teamService.update(teamId, input, userId);
}
@Public()
@Get('driver/:driverId')
@ApiOperation({ summary: 'Get driver\'s team' })
@ApiResponse({ status: 200, description: 'Driver\'s team', type: GetDriverTeamOutputDTO })
@@ -77,6 +82,7 @@ export class TeamController {
return await this.teamService.getDriverTeam(driverId);
}
@Public()
@Get(':teamId/members/:driverId')
@ApiOperation({ summary: 'Get team membership for a driver' })
@ApiResponse({ status: 200, description: 'Team membership', type: GetTeamMembershipOutputDTO })

View File

@@ -7,6 +7,9 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { AppModule } from './app.module';
import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
async function bootstrap() {
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined);
@@ -19,6 +22,12 @@ async function bootstrap() {
}),
);
app.useGlobalGuards(
app.get(AuthenticationGuard),
app.get(AuthorizationGuard),
app.get(FeatureAvailabilityGuard),
);
// Swagger/OpenAPI configuration
const config = new DocumentBuilder()
.setTitle('GridPilot API')

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -44,13 +45,42 @@ import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardVie
export default function SponsorDashboardPage() {
const shouldReduceMotion = useReducedMotion();
const { sponsorService } = useServices();
const { sponsorService, policyService } = useServices();
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<SponsorDashboardViewModel | null>(null);
const [error, setError] = useState<string | null>(null);
const {
data: policySnapshot,
isLoading: policyLoading,
isError: policyError,
} = useQuery({
queryKey: ['policySnapshot'],
queryFn: () => policyService.getSnapshot(),
staleTime: 60_000,
gcTime: 5 * 60_000,
});
const sponsorPortalState = policySnapshot
? policyService.getCapabilityState(policySnapshot, 'sponsors.portal')
: null;
useEffect(() => {
if (policyLoading) {
return;
}
if (policyError || sponsorPortalState !== 'enabled') {
setError(
sponsorPortalState === 'coming_soon'
? 'Sponsor portal is coming soon.'
: 'Sponsor portal is currently unavailable.',
);
setLoading(false);
return;
}
const loadDashboard = async () => {
try {
const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1');
@@ -67,8 +97,8 @@ export default function SponsorDashboardPage() {
}
};
loadDashboard();
}, []);
void loadDashboard();
}, [policyLoading, policyError, sponsorPortalState, sponsorService]);
if (loading) {
return (

View File

@@ -7,6 +7,7 @@ import Link from 'next/link';
import React, { useEffect, useMemo, useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { CapabilityGate } from '@/components/shared/CapabilityGate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel';
@@ -190,40 +191,54 @@ export default function UserPill() {
</div>
{/* Menu Items */}
<div className="py-2 text-sm text-gray-200">
<Link
href="/sponsor"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BarChart3 className="h-4 w-4 text-performance-green" />
<span>Dashboard</span>
</Link>
<Link
href="/sponsor/campaigns"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Megaphone className="h-4 w-4 text-primary-blue" />
<span>My Sponsorships</span>
</Link>
<Link
href="/sponsor/billing"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<CreditCard className="h-4 w-4 text-warning-amber" />
<span>Billing</span>
</Link>
<Link
href="/sponsor/settings"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4 text-gray-400" />
<span>Settings</span>
</Link>
</div>
<CapabilityGate
capabilityKey="sponsors.portal"
fallback={
<div className="py-2 px-4 text-xs text-gray-500">
Sponsor portal is currently unavailable.
</div>
}
comingSoon={
<div className="py-2 px-4 text-xs text-gray-500">
Sponsor portal is coming soon.
</div>
}
>
<div className="py-2 text-sm text-gray-200">
<Link
href="/sponsor"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<BarChart3 className="h-4 w-4 text-performance-green" />
<span>Dashboard</span>
</Link>
<Link
href="/sponsor/campaigns"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Megaphone className="h-4 w-4 text-primary-blue" />
<span>My Sponsorships</span>
</Link>
<Link
href="/sponsor/billing"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<CreditCard className="h-4 w-4 text-warning-amber" />
<span>Billing</span>
</Link>
<Link
href="/sponsor/settings"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Settings className="h-4 w-4 text-gray-400" />
<span>Settings</span>
</Link>
</div>
</CapabilityGate>
{/* Footer */}
<div className="border-t border-charcoal-outline">

View File

@@ -0,0 +1,44 @@
'use client';
import { ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
type CapabilityGateProps = {
capabilityKey: string;
children: ReactNode;
fallback?: ReactNode;
comingSoon?: ReactNode;
};
export function CapabilityGate({
capabilityKey,
children,
fallback = null,
comingSoon = null,
}: CapabilityGateProps) {
const { policyService } = useServices();
const { data, isLoading, isError } = useQuery({
queryKey: ['policySnapshot'],
queryFn: () => policyService.getSnapshot(),
staleTime: 60_000,
gcTime: 5 * 60_000,
});
if (isLoading || isError || !data) {
return <>{fallback}</>;
}
const state = policyService.getCapabilityState(data, capabilityKey);
if (state === 'enabled') {
return <>{children}</>;
}
if (state === 'coming_soon') {
return <>{comingSoon ?? fallback}</>;
}
return <>{fallback}</>;
}

View File

@@ -0,0 +1,28 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type { ErrorReporter } from '../../interfaces/ErrorReporter';
import type { Logger } from '../../interfaces/Logger';
export type OperationalMode = 'normal' | 'maintenance' | 'test';
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
export type PolicySnapshotDto = {
policyVersion: number;
operationalMode: OperationalMode;
maintenanceAllowlist: {
view: string[];
mutate: string[];
};
capabilities: Record<string, FeatureState>;
loadedFrom: 'env' | 'file' | 'defaults';
loadedAtIso: string;
};
export class PolicyApiClient extends BaseApiClient {
constructor(baseUrl: string, errorReporter: ErrorReporter, logger: Logger) {
super(baseUrl, errorReporter, logger);
}
getSnapshot(): Promise<PolicySnapshotDto> {
return this.get<PolicySnapshotDto>('/policy/snapshot');
}
}

View File

@@ -0,0 +1,66 @@
import { Blocker } from './Blocker';
import type { PolicySnapshotDto } from '../api/policy/PolicyApiClient';
import { PolicyService } from '../services/policy/PolicyService';
export type CapabilityBlockReason = 'loading' | 'enabled' | 'coming_soon' | 'disabled' | 'hidden';
export class CapabilityBlocker extends Blocker {
private snapshot: PolicySnapshotDto | null = null;
constructor(
private readonly policyService: PolicyService,
private readonly capabilityKey: string,
) {
super();
}
updateSnapshot(snapshot: PolicySnapshotDto | null): void {
this.snapshot = snapshot;
}
canExecute(): boolean {
return this.getReason() === 'enabled';
}
getReason(): CapabilityBlockReason {
if (!this.snapshot) {
return 'loading';
}
return this.policyService.getCapabilityState(this.snapshot, this.capabilityKey);
}
block(): void {
this.snapshot = {
...(this.snapshot ?? {
policyVersion: 0,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date().toISOString(),
}),
capabilities: {
...(this.snapshot?.capabilities ?? {}),
[this.capabilityKey]: 'disabled',
},
};
}
release(): void {
this.snapshot = {
...(this.snapshot ?? {
policyVersion: 0,
operationalMode: 'normal',
maintenanceAllowlist: { view: [], mutate: [] },
capabilities: {},
loadedFrom: 'defaults',
loadedAtIso: new Date().toISOString(),
}),
capabilities: {
...(this.snapshot?.capabilities ?? {}),
[this.capabilityKey]: 'enabled',
},
};
}
}

View File

@@ -1,3 +1,4 @@
export { Blocker } from './Blocker';
export { CapabilityBlocker } from './CapabilityBlocker';
export { SubmitBlocker } from './SubmitBlocker';
export { ThrottleBlocker } from './ThrottleBlocker';

View File

@@ -9,6 +9,7 @@ import { AuthApiClient } from '../api/auth/AuthApiClient';
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
import { MediaApiClient } from '../api/media/MediaApiClient';
import { DashboardApiClient } from '../api/dashboard/DashboardApiClient';
import { PolicyApiClient } from '../api/policy/PolicyApiClient';
import { ProtestsApiClient } from '../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../api/penalties/PenaltiesApiClient';
import { PenaltyService } from './penalties/PenaltyService';
@@ -42,6 +43,7 @@ import { MembershipFeeService } from './payments/MembershipFeeService';
import { AuthService } from './auth/AuthService';
import { SessionService } from './auth/SessionService';
import { ProtestService } from './protests/ProtestService';
import { PolicyService } from './policy/PolicyService';
import { OnboardingService } from './onboarding/OnboardingService';
/**
@@ -67,6 +69,7 @@ export class ServiceFactory {
analytics: AnalyticsApiClient;
media: MediaApiClient;
dashboard: DashboardApiClient;
policy: PolicyApiClient;
protests: ProtestsApiClient;
penalties: PenaltiesApiClient;
};
@@ -85,6 +88,7 @@ export class ServiceFactory {
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
media: new MediaApiClient(baseUrl, this.errorReporter, this.logger),
dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger),
policy: new PolicyApiClient(baseUrl, this.errorReporter, this.logger),
protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger),
penalties: new PenaltiesApiClient(baseUrl, this.errorReporter, this.logger),
};
@@ -237,12 +241,19 @@ export class ServiceFactory {
}
/**
* Create DashboardService instance
* Create PolicyService instance
*/
createDashboardService(): DashboardService {
return new DashboardService(this.apiClients.dashboard);
createPolicyService(): PolicyService {
return new PolicyService(this.apiClients.policy);
}
/**
* Create DashboardService instance
*/
createDashboardService(): DashboardService {
return new DashboardService(this.apiClients.dashboard);
}
/**
* Create MediaService instance
*/

View File

@@ -31,6 +31,7 @@ import { SponsorshipService } from './sponsors/SponsorshipService';
import { TeamJoinService } from './teams/TeamJoinService';
import { TeamService } from './teams/TeamService';
import { OnboardingService } from './onboarding/OnboardingService';
import { PolicyService } from './policy/PolicyService';
import { LandingService } from './landing/LandingService';
export interface Services {
@@ -60,6 +61,7 @@ export interface Services {
protestService: ProtestService;
penaltyService: PenaltyService;
onboardingService: OnboardingService;
policyService: PolicyService;
landingService: LandingService;
}
@@ -109,6 +111,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
protestService: serviceFactory.createProtestService(),
penaltyService: serviceFactory.createPenaltyService(),
onboardingService: serviceFactory.createOnboardingService(),
policyService: serviceFactory.createPolicyService(),
landingService: serviceFactory.createLandingService(),
};
}, []);

View File

@@ -0,0 +1,17 @@
import type { FeatureState, PolicyApiClient, PolicySnapshotDto } from '../../api/policy/PolicyApiClient';
export class PolicyService {
constructor(private readonly apiClient: PolicyApiClient) {}
getSnapshot(): Promise<PolicySnapshotDto> {
return this.apiClient.getSnapshot();
}
getCapabilityState(snapshot: PolicySnapshotDto, capabilityKey: string): FeatureState {
return snapshot.capabilities[capabilityKey] ?? 'hidden';
}
isCapabilityEnabled(snapshot: PolicySnapshotDto, capabilityKey: string): boolean {
return this.getCapabilityState(snapshot, capabilityKey) === 'enabled';
}
}

View File

@@ -14,7 +14,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
export type GetRaceDetailInput = {
raceId: string;
driverId: string;
driverId?: string;
};
// Backwards-compatible alias for older callers
@@ -60,7 +60,7 @@ export class GetRaceDetailUseCase {
const [league, registrations, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.findByRaceId(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
driverId ? this.leagueMembershipRepository.getMembership(race.leagueId, driverId) : Promise.resolve(null),
]);
const drivers = await Promise.all(
@@ -69,14 +69,20 @@ export class GetRaceDetailUseCase {
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId);
const isUserRegistered =
typeof driverId === 'string' && driverId.length > 0
? registrations.some(reg => reg.driverId.toString() === driverId)
: false;
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister =
!!membership && membership.status.toString() === 'active' && isUpcoming;
typeof driverId === 'string' && driverId.length > 0
? !!membership && membership.status.toString() === 'active' && isUpcoming
: false;
let userResult: RaceResult | null = null;
if (race.status === 'completed') {
if (race.status === 'completed' && typeof driverId === 'string' && driverId.length > 0) {
const results = await this.resultRepository.findByRaceId(race.id);
userResult = results.find(r => r.driverId.toString() === driverId) ?? null;
}

View File

@@ -0,0 +1,256 @@
# Authorization (Roles + Permissions)
This document defines the **authorization concept** for GridPilot, based on a clear role taxonomy and a permission-first model that scales to:
- system/global admins
- league-scoped admins/stewards
- sponsor-scoped admins
- team-scoped admins
- future “super admin” tooling
It complements (but does not replace) feature availability:
- Feature availability answers: “Is this capability enabled at all?”
- Authorization answers: “Is this actor allowed to do it?”
Related:
- Feature gating concept: docs/architecture/FEATURE_AVAILABILITY.md
---
## 1) Terms
### 1.1 Actor
The authenticated user performing a request.
### 1.2 Resource Scope
A resource boundary that defines where a role applies:
- **system**: global platform scope
- **league**: role applies only inside a league
- **sponsor**: role applies only inside a sponsor account
- **team**: role applies only inside a team
### 1.3 Permission
A normalized action on a capability, expressed as:
- `capabilityKey`
- `actionType` (`view` or `mutate`)
Examples:
- `league.admin.members` + `mutate`
- `league.stewarding.protests` + `view`
- `sponsors.portal` + `view`
---
## 2) Role Taxonomy (Canonical)
These are the roles you described, organized by scope.
### 2.1 System Roles (global)
- `owner`
Highest authority. Intended for a tiny set of internal operators.
- `admin`
Platform admin. Can manage most platform features.
### 2.2 League Roles (scoped to a leagueId)
- `league_owner`
Full control over that league.
- `league_admin`
Admin control over that league.
- `league_steward`
Stewarding workflow privileges (protests, penalties, reviews), plus any explicitly granted admin powers.
### 2.3 Sponsor Roles (scoped to a sponsorId)
- `sponsor_owner`
Full control over that sponsor account.
- `sponsor_admin`
Admin control for sponsor account operations.
### 2.4 Team Roles (scoped to a teamId)
- `team_owner`
Full control over that team.
- `team_admin`
Admin control for team operations.
### 2.5 Default Role
- `user`
Every authenticated account has this implicitly.
Notes:
- “Role” is an access label; it is not a separate identity type. Admins, drivers, team captains are still “users”.
---
## 3) Role Composition Rules
Authorization is evaluated with **role composition**:
1) **System roles** apply everywhere.
2) **Scoped roles** apply only when the request targets that scope.
Examples:
- A user can be `league_admin` in League A and just `user` in League B.
- A system `admin` is allowed even without scoped roles (unless an endpoint explicitly requires scoped membership).
---
## 4) Permission-First Model (Recommended)
Instead of scattering checks like “is admin?” across controllers/services, define:
- a small, stable set of permissions (capabilityKey + actionType)
- a role → permission mapping table
- membership resolvers that answer: “what scoped roles does this actor have for this resourceId?”
### 4.1 Why permission-first
- Centralizes security logic
- Makes audit/review simpler
- Avoids “new endpoint forgot a check”
- Enables future super-admin tooling by manipulating roles/permissions cleanly
---
## 5) Default Access Policy (Protect All Endpoints)
To properly “protect all endpoints”, the platform must move to:
### 5.1 Deny-by-default
- Every API route requires an authenticated actor **unless explicitly marked public**.
### 5.2 Explicit public routes
A route is public only when explicitly marked as such (conceptually “Public metadata”).
This prevents “we forgot to add guards” from becoming a security issue.
### 5.3 Actor identity must not be caller-controlled
Any endpoint that currently accepts identifiers like:
- `performerDriverId`
- `adminId`
- `stewardId`
must stop trusting those fields and derive the actor identity from the authenticated session.
---
## 6) 403 vs 404 (Non-Disclosure Rules)
Use different status codes for different security goals:
### 6.1 Forbidden (403)
Return **403** when:
- the resource exists
- the actor is authenticated
- the actor lacks permission
This is the normal authorization failure.
### 6.2 Not Found (404) for non-disclosure
Return **404** when:
- revealing the existence of the resource would leak sensitive information
- the route is explicitly designated “non-disclosing”
Use this sparingly and intentionally.
### 6.3 Feature availability interaction
Feature availability failures (disabled/hidden/coming soon) should behave as “not found” for public callers, while maintenance mode should return 503. See docs/architecture/FEATURE_AVAILABILITY.md.
---
## 7) Suggested Role → Permission Mapping (First Pass)
This table is a starting point (refine as product scope increases).
### 7.1 System
- `owner`: all permissions
- `admin`: platform-admin permissions (payments admin, sponsor portal admin, moderation)
### 7.2 League
- `league_owner`: all league permissions for that league
- `league_admin`: league management permissions (members, config, seasons, schedule, wallet)
- `league_steward`: stewarding permissions (review protests, apply penalties), and optionally limited admin view permissions
### 7.3 Sponsor
- `sponsor_owner`: all sponsor permissions for that sponsor
- `sponsor_admin`: sponsor operational permissions (view dashboard, manage sponsorship requests, manage sponsor settings)
### 7.4 Team
- `team_owner`: all team permissions for that team
- `team_admin`: team management permissions (update team, manage roster, handle join requests)
---
## 8) Membership Resolvers (Clean Architecture Boundary)
Authorization needs a clean boundary for “does actor have a scoped role for this resource?”
Conceptually:
- League membership repository answers: actors role in leagueId
- Team membership repository answers: actors role in teamId
- Sponsor membership repository answers: actors role in sponsorId
This keeps persistence details out of controllers and allows in-memory adapters for tests.
---
## 9) Example Endpoint Policies (Conceptual)
### 9.1 Public read
- Public league standings page:
- Feature availability: `league.public` view (if you want to gate)
- Authorization: public route (no login)
### 9.2 League admin mutation
- Remove a member from league:
- Requires login
- Requires league scope
- Requires `league.admin.members` mutate
- Returns 403 if not allowed; 404 only if non-disclosure is intended
### 9.3 Stewarding review
- Review protest:
- Requires login
- Requires league scope derived from the protests race/league
- Requires `league.stewarding.protests` mutate
- Actor must be derived from session, not from request body
### 9.4 Payments
- Payments endpoints:
- Requires login
- Likely requires system `admin` or `owner`
---
## 10) Data Flow (Conceptual)
```mermaid
flowchart LR
Req[HTTP Request] --> AuthN[Authenticate actor]
AuthN --> Scope[Resolve resource scope]
Scope --> Roles[Load actor roles for scope]
Roles --> Perms[Evaluate required permissions]
Perms --> Allow{Allow}
Allow -->|Yes| Handler[Route handler]
Allow -->|No| Deny[Deny 401 or 403 or 404]
```
Rules:
- AuthN attaches actor identity to the request.
- Scope resolution loads resource context (leagueId, teamId, sponsorId) from route params or from looked-up entities.
- Required permissions must be declared at the boundary (controller/route metadata).
- Deny-by-default means anything not marked public requires an actor.
---
## 11) What This Enables Later
- A super-admin UI can manage:
- global roles (owner/admin)
- scoped roles (league_owner/admin/steward, sponsor_owner/admin, team_owner/admin)
- Feature availability remains a separate control plane (maintenance mode, coming soon, kill switches), documented in docs/architecture/FEATURE_AVAILABILITY.md.

View File

@@ -0,0 +1,315 @@
# Feature Availability (Modes + Feature Flags)
This document defines a clean, consistent system for enabling/disabling functionality across:
- API endpoints
- Website links/navigation
- Website components
It is designed to support:
- test mode
- maintenance mode
- disabling features due to risk/issues
- coming soon features
- future super admin flag management
It is aligned with the hard separation of responsibilities in `Blockers & Guards`:
- Frontend uses Blockers (UX best-effort)
- Backend uses Guards (authoritative enforcement)
See: docs/architecture/BLOCKER_GUARDS.md
---
## 1) Core Principle
Availability is decided once, then applied in multiple places.
- Backend Guards enforce availability for correctness and security.
- Frontend Blockers reflect availability for UX, but must never be relied on for enforcement.
If it must be enforced, it is a Guard.
If it only improves UX, it is a Blocker.
---
## 2) Definitions (Canonical Vocabulary)
### 2.1 Operational Mode (system-level)
A small, global state representing operational posture.
Recommended enum:
- normal
- maintenance
- test
Operational Mode is:
- authoritative in backend
- typically environment-scoped
- required for rapid response (maintenance must be runtime-changeable)
### 2.2 Feature State (capability-level)
A per-feature state machine (not a boolean).
Recommended enum:
- enabled
- disabled
- coming_soon
- hidden
Semantics:
- enabled: feature is available and advertised
- disabled: feature exists but must not be used (safety kill switch)
- coming_soon: may be visible in UI as teaser, but actions are blocked
- hidden: not visible/advertised; actions are blocked (safest default)
### 2.3 Capability
A named unit of functionality (stable key) used consistently across API + website.
Examples:
- races.create
- payments.checkout
- sponsor.portal
- stewarding.protests
A capability key is a contract.
### 2.4 Action Type
Availability decisions vary by the type of action:
- view: read-only operations (pages, GET endpoints)
- mutate: state-changing operations (POST/PUT/PATCH/DELETE)
---
## 3) Policy Model (What Exists)
### 3.1 FeatureAvailabilityPolicy (single evaluation model)
One evaluation function produces a decision.
Inputs:
- environment (dev/test/prod)
- operationalMode (normal/maintenance/test)
- capabilityKey (string)
- actionType (view/mutate)
- actorContext (anonymous/authenticated; roles later)
Outputs:
- allow: boolean
- publicReason: one of maintenance | disabled | coming_soon | hidden | not_configured
- uxHint: optional { messageKey, redirectPath, showTeaser }
The same decision model is reused by:
- API Guard enforcement
- Website navigation visibility
- Website component rendering/disablement
### 3.2 Precedence (where values come from)
To avoid “mystery behavior”, use strict precedence:
1. runtime overrides (highest priority)
2. build-time environment configuration
3. code defaults (lowest priority, should be safe: hidden/disabled)
Rationale:
- runtime overrides enable emergency response without rebuild
- env config enables environment-specific defaults
- code defaults keep behavior deterministic if config is missing
---
## 4) Evaluation Rules (Deterministic, Explicit)
### 4.1 Maintenance mode rules
Maintenance must be able to block the platform fast and consistently.
Default behavior:
- mutate actions: denied unless explicitly allowlisted
- view actions: allowed only for a small allowlist (status page, login, health, static public routes)
This creates a safe “fail closed” posture.
Optional refinement:
- define a maintenance allowlist for critical reads (e.g., dashboards for operators)
### 4.2 Test mode rules
Test mode should primarily exist in non-prod, and should be explicit in prod.
Recommended behavior:
- In prod, test mode should not be enabled accidentally.
- In test environments, test mode may:
- enable test-only endpoints
- bypass external integrations (through adapters)
- relax rate limits
- expose test banners in UI (Blocker-level display)
### 4.3 Feature state rules (per capability)
Given a capability state:
- enabled:
- allow view + mutate (subject to auth/roles)
- visible in UI
- coming_soon:
- allow view of teaser pages/components
- deny mutate and deny sensitive reads
- visible in UI with Coming Soon affordances
- disabled:
- deny view + mutate
- hidden in nav by default
- hidden:
- deny view + mutate
- never visible in UI
Note:
- “disabled” and “hidden” are both blocked; the difference is UI and information disclosure.
### 4.4 Missing configuration
If a capability is not configured:
- treat as hidden (fail closed)
- optionally log a warning (server-side)
---
## 5) Enforcement Mapping (Where Each Requirement Lives)
This section is the “wiring contract” across layers.
### 5.1 API endpoints (authoritative)
- Enforce via Backend Guards (NestJS CanActivate).
- Endpoints must declare the capability they require.
Mapping to HTTP:
- maintenance: 503 Service Unavailable (preferred for global maintenance)
- disabled/hidden: 404 Not Found (avoid advertising unavailable capabilities)
- coming_soon: 404 Not Found publicly, or 409 Conflict internally if you want explicit semantics for trusted clients later
Guideline:
- External clients should not get detailed feature availability information unless explicitly intended.
### 5.2 Website links / navigation (UX)
- Enforce via Frontend Blockers.
- Hide links when state is disabled/hidden.
- For coming_soon, show link but route to teaser page or disable with explanation.
Rules:
- Never assume hidden in UI equals enforced on server.
- UI should degrade gracefully (API may still block).
### 5.3 Website components (UX)
- Use Blockers to:
- hide components for hidden/disabled
- show teaser content for coming_soon
- disable buttons or flows for coming_soon/disabled, with consistent messaging
Recommendation:
- Provide a single reusable component (FeatureBlocker) that consumes policy decisions and renders:
- children when allowed
- teaser when coming_soon
- null or fallback when disabled/hidden
---
## 6) Build-Time vs Runtime (Clean, Predictable)
### 6.1 Build-time flags (require rebuild/redeploy)
What they are good for:
- preventing unfinished UI code from shipping in a bundle
- cutting entire routes/components from builds for deterministic releases
Limitations:
- NEXT_PUBLIC_* values are compiled into the client bundle; changing them does not update clients without rebuild.
Use build-time flags for:
- experimental UI
- “not yet shipped” components/routes
- simplifying deployments (pre-launch vs alpha style gating)
### 6.2 Runtime flags (no rebuild)
What they are for:
- maintenance mode
- emergency disable for broken endpoints
- quickly hiding risky features
Runtime flags must be available to:
- API Guards (always)
- Website SSR/middleware optionally
- Website client optionally (for UX only)
Key tradeoff:
- runtime access introduces caching and latency concerns
- treat runtime policy reads as cached, fast, and resilient
Recommended approach:
- API is authoritative source of runtime policy
- website can optionally consume a cached policy snapshot endpoint
---
## 7) Storage and Distribution (Now + Future Super Admin)
### 7.1 Now (no super admin UI)
Use a single “policy snapshot” stored in one place and read by the API, with caching.
Options (in priority order):
1. Remote KV/DB-backed policy snapshot (preferred for true runtime changes)
2. Environment variable JSON (simpler, but changes require restart/redeploy)
3. Static config file in repo (requires rebuild/redeploy)
### 7.2 Future (super admin UI)
Super admin becomes a writer to the same store.
Non-negotiable:
- The storage schema must be stable and versioned.
Recommended schema (conceptual):
- policyVersion
- operationalMode
- capabilities: map of capabilityKey -> featureState
- allowlists: maintenance view/mutate allowlists
- optional targeting rules later (by role/user)
---
## 8) Data Flow (Conceptual)
```mermaid
flowchart LR
UI[Website UI] --> FB[Frontend Blockers]
FB --> PC[Policy Client]
UI --> API[API Request]
API --> FG[Feature Guard]
FG --> AS[API Application Service]
AS --> UC[Core Use Case]
PC --> PS[Policy Snapshot]
FG --> PS
```
Interpretation:
- Website reads policy for UX (best-effort).
- API enforces policy (authoritative) before any application logic.
---
## 9) Implementation Checklist (For Code Mode)
Backend (apps/api):
- Define capability keys and feature states as shared types in a local module.
- Create FeaturePolicyService that resolves the current policy snapshot (cached).
- Add FeatureFlagGuard (or FeatureAvailabilityGuard) that:
- reads required capability metadata for an endpoint
- evaluates allow/deny with actionType
- maps denial to the chosen HTTP status codes
Frontend (apps/website):
- Add a small PolicyClient that fetches policy snapshot from API (optional for phase 1).
- Add FeatureBlocker component for consistent UI behavior.
- Centralize navigation link definitions and filter them via policy.
Ops/Config:
- Define how maintenance mode is toggled (KV/DB entry or config endpoint restricted to operators later).
- Ensure defaults are safe (fail closed).
---
## 10) Non-Goals (Explicit)
- This system is not an authorization system.
- Roles/permissions are separate (but can be added as actorContext inputs later).
- Blockers never replace Guards.