authentication authorization
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal file
164
apps/api/src/domain/auth/AuthGuards.http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal file
54
apps/api/src/domain/auth/AuthenticationGuard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal file
30
apps/api/src/domain/auth/AuthenticationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal file
89
apps/api/src/domain/auth/AuthorizationGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal file
67
apps/api/src/domain/auth/AuthorizationGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal file
76
apps/api/src/domain/auth/AuthorizationService.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/domain/auth/Public.ts
Normal file
13
apps/api/src/domain/auth/Public.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal file
13
apps/api/src/domain/auth/RequireAuthenticatedUser.ts
Normal 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);
|
||||
}
|
||||
13
apps/api/src/domain/auth/RequireRoles.ts
Normal file
13
apps/api/src/domain/auth/RequireRoles.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
|
||||
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal file
91
apps/api/src/domain/policy/FeatureAvailabilityGuard.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
18
apps/api/src/domain/policy/PolicyController.ts
Normal file
18
apps/api/src/domain/policy/PolicyController.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
apps/api/src/domain/policy/PolicyModule.ts
Normal file
11
apps/api/src/domain/policy/PolicyModule.ts
Normal 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 {}
|
||||
229
apps/api/src/domain/policy/PolicyService.ts
Normal file
229
apps/api/src/domain/policy/PolicyService.ts
Normal 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;
|
||||
}
|
||||
24
apps/api/src/domain/policy/RequireCapability.ts
Normal file
24
apps/api/src/domain/policy/RequireCapability.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
44
apps/website/components/shared/CapabilityGate.tsx
Normal file
44
apps/website/components/shared/CapabilityGate.tsx
Normal 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}</>;
|
||||
}
|
||||
28
apps/website/lib/api/policy/PolicyApiClient.ts
Normal file
28
apps/website/lib/api/policy/PolicyApiClient.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
66
apps/website/lib/blockers/CapabilityBlocker.ts
Normal file
66
apps/website/lib/blockers/CapabilityBlocker.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Blocker } from './Blocker';
|
||||
export { CapabilityBlocker } from './CapabilityBlocker';
|
||||
export { SubmitBlocker } from './SubmitBlocker';
|
||||
export { ThrottleBlocker } from './ThrottleBlocker';
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
17
apps/website/lib/services/policy/PolicyService.ts
Normal file
17
apps/website/lib/services/policy/PolicyService.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
256
docs/architecture/AUTHORIZATION.md
Normal file
256
docs/architecture/AUTHORIZATION.md
Normal 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: actor’s role in leagueId
|
||||
- Team membership repository answers: actor’s role in teamId
|
||||
- Sponsor membership repository answers: actor’s 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 protest’s 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.
|
||||
315
docs/architecture/FEATURE_AVAILABILITY.md
Normal file
315
docs/architecture/FEATURE_AVAILABILITY.md
Normal 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.
|
||||
Reference in New Issue
Block a user