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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions backend/docs/API_VERSIONING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# API Versioning

The backend now supports concurrent API versions for versioned resources.

## Supported selectors

- URL path: `/api/v1/puzzles`, `/api/v2/puzzles`
- Header: `X-API-Version: 1`
- Query parameter: `?api_version=1`

If more than one selector is provided, all explicit values must match. Conflicts return `400 Bad Request`.

## Resolution order

1. URL path version
2. Header version
3. Query parameter version
4. Latest active version when no version is supplied

Current default version: `v2`

## Lifecycle

- `v2`: active
- `v1`: deprecated, sunset scheduled for `2026-06-24T00:00:00.000Z`
- `v0`: removed and returns `410 Gone`

Deprecated responses include:

- `X-API-Deprecation: true`
- `Warning: 299 - "..."`
- `Sunset: <rfc1123 date>` when a sunset date exists

Every versioned response includes:

- `X-API-Version`
- `X-API-Latest-Version`
- `X-API-Version-Status`

## Version differences

### v1 puzzles

- Pagination uses `page` and `limit`
- Item endpoints return the raw puzzle object
- Collection endpoints return `{ data, meta: { page, limit, total } }`

### v2 puzzles

- Pagination uses `page` and `pageSize`
- `pageSize` is capped at 50
- Item endpoints return `{ data, version }`
- Collection endpoints return `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }`

## Auto-generated docs

- Latest: `/api/docs/latest`
- v1: `/api/docs/v1`
- v2: `/api/docs/v2`
60 changes: 60 additions & 0 deletions backend/docs/RBAC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Role-Based Access Control

The backend uses a route decorator plus guard for role-based access control.

## Supported roles

- `USER`
- `MODERATOR`
- `ADMIN`

Canonical enum: `backend/src/users/enums/userRole.enum.ts`

## Hierarchy

- `ADMIN` inherits `MODERATOR` and `USER` permissions
- `MODERATOR` inherits `USER` permissions
- `USER` only has `USER` permissions

## Basic usage

```ts
@Roles(userRole.ADMIN)
@Post()
createPuzzle() {}
```

This returns `403 Forbidden` with:

```txt
Access denied. Required role: ADMIN
```

## Multiple roles (OR logic)

```ts
@Roles(userRole.ADMIN, userRole.MODERATOR)
@Get()
findAllUsers() {}
```

Any listed role is enough.

## Ownership-aware access

```ts
@Roles({ roles: [userRole.ADMIN], ownership: { param: 'id' } })
@Patch(':id')
updateUser() {}
```

This allows either:

- an `ADMIN`
- the authenticated user whose `userId` matches `req.params.id`

## Notes

- RBAC runs after authentication middleware and expects `request.user.userRole`
- Missing role information in the auth context is treated as a server error
- Denied access attempts are logged for audit/security review
9 changes: 9 additions & 0 deletions backend/docs/migrations/v0-to-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Migration Guide: v0 to v1

Version 0 has been removed and now returns `410 Gone`.

## Required action

1. Move all clients to `/api/v1/*`
2. Update any fallback version headers or query parameters to `1`
3. Re-test integrations against the maintained v1 contract
37 changes: 37 additions & 0 deletions backend/docs/migrations/v1-to-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Migration Guide: v1 to v2

This guide covers the breaking changes between puzzle API v1 and v2.

## Routing

- Preferred: move from `/api/v1/puzzles` to `/api/v2/puzzles`
- Alternative negotiation also works with `X-API-Version: 2` or `?api_version=2`

## Response contract changes

- `GET /puzzles/:id`
- v1: returns a puzzle object directly
- v2: returns `{ data: <puzzle>, version: "2" }`

- `GET /puzzles`
- v1: returns `{ data, meta: { page, limit, total } }`
- v2: returns `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }`

- `GET /puzzles/daily-quest`
- v1: returns `Puzzle[]`
- v2: returns `{ data: Puzzle[], meta: ... }`

## Request contract changes

- Replace `limit` with `pageSize`
- Expect stricter validation in v2:
- `pageSize` max is `50`
- unsupported legacy query fields are rejected by the validation pipe

## Suggested frontend rollout

1. Update API client defaults to send `X-API-Version: 2`
2. Adjust response mappers for the new envelope format
3. Replace any `limit` usage with `pageSize`
4. Monitor deprecation headers while v1 traffic drains
5. Remove v1-specific parsing before the sunset date
19 changes: 15 additions & 4 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import jwtConfig from './auth/authConfig/jwt.config';
import { UsersService } from './users/providers/users.service';
import { GeolocationMiddleware } from './common/middleware/geolocation.middleware';
import { HealthModule } from './health/health.module';
import {
ApiVersionMiddleware,
ApiVersionService,
} from './common/versioning';
import { DocsController } from './docs/docs.controller';
import { RolesGuard } from './roles/roles.guard';

// const ENV = process.env.NODE_ENV;
// console.log('NODE_ENV:', process.env.NODE_ENV);
Expand Down Expand Up @@ -98,13 +104,13 @@ import { HealthModule } from './health/health.module';
redisClient: redisClient,
validateUser: async (userId: string) => await usersService.findOneById(userId),
logging: true,
publicRoutes: ['/auth', '/api', '/docs', '/health'],
publicRoutes: ['/api/auth', '/api/docs', '/health'],
}),
}),
HealthModule,
],
controllers: [AppController],
providers: [AppService],
controllers: [AppController, DocsController],
providers: [AppService, ApiVersionService, RolesGuard],
})
export class AppModule implements NestModule {
/**
Expand All @@ -115,10 +121,15 @@ export class AppModule implements NestModule {
.apply(GeolocationMiddleware)
.forRoutes('*');

consumer
.apply(ApiVersionMiddleware)
.forRoutes({ path: 'api/*path', method: RequestMethod.ALL });

consumer
.apply(JwtAuthMiddleware)
.exclude(
{ path: 'auth/(.*)', method: RequestMethod.ALL },
{ path: 'api/auth/(.*)', method: RequestMethod.ALL },
{ path: 'api/docs/(.*)', method: RequestMethod.GET },
{ path: 'api', method: RequestMethod.GET },
{ path: 'docs', method: RequestMethod.GET },
{ path: 'health', method: RequestMethod.GET },
Expand Down
5 changes: 5 additions & 0 deletions backend/src/auth/interfaces/activeInterface.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { userRole } from '../../users/enums/userRole.enum';

/**Active user data interface */
export interface ActiveUserData {
/**sub of type number */
sub: string;

/**email of type string */
email?: string;

/**authenticated user role */
userRole?: userRole;
}
5 changes: 3 additions & 2 deletions backend/src/auth/middleware/jwt-auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as jwt from 'jsonwebtoken';
import { userRole } from '../../users/enums/userRole.enum';

/**
* Interface for the Redis client to support token blacklisting.
Expand Down Expand Up @@ -40,7 +41,7 @@ export interface JwtAuthMiddlewareOptions {
export interface DecodedUserPayload {
userId: string;
email: string;
userRole: string;
userRole: userRole;
[key: string]: any;
}

Expand Down Expand Up @@ -124,7 +125,7 @@ export class JwtAuthMiddleware implements NestMiddleware {
const userPayload: DecodedUserPayload = {
userId,
email: decoded.email,
userRole: decoded.userRole || decoded.role,
userRole: (decoded.userRole || decoded.role || userRole.USER) as userRole,
};

if (!userPayload.userId || !userPayload.email) {
Expand Down
39 changes: 39 additions & 0 deletions backend/src/common/versioning/api-version.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiVersionDefinition } from './api-version.types';

export const API_VERSION_HEADER = 'x-api-version';
export const API_VERSION_QUERY_PARAM = 'api_version';
export const API_VERSION_ROUTE_PREFIX = 'v';

export const VERSIONED_RESOURCES = ['puzzles'] as const;

export const API_VERSION_DEFINITIONS: ApiVersionDefinition[] = [
{
version: '0',
status: 'removed',
releaseDate: '2025-01-15T00:00:00.000Z',
deprecated: true,
deprecationMessage:
'Version 0 has been removed. Upgrade to v1 or v2 immediately.',
removedAt: '2025-12-31T23:59:59.000Z',
successorVersion: '1',
supportedResources: ['puzzles'],
},
{
version: '1',
status: 'deprecated',
releaseDate: '2025-06-01T00:00:00.000Z',
deprecated: true,
deprecationMessage:
'Version 1 remains available during the migration window. Plan your upgrade to v2.',
sunsetDate: '2026-06-24T00:00:00.000Z',
successorVersion: '2',
supportedResources: ['puzzles'],
},
{
version: '2',
status: 'active',
releaseDate: '2026-03-26T00:00:00.000Z',
deprecated: false,
supportedResources: ['puzzles'],
},
];
49 changes: 49 additions & 0 deletions backend/src/common/versioning/api-version.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ApiVersionService } from './api-version.service';

@Injectable()
export class ApiVersionInterceptor implements NestInterceptor {
constructor(private readonly apiVersionService: ApiVersionService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest();
const response = httpContext.getResponse();
const versionContext = request.apiVersionContext;

if (versionContext) {
const { definition, resolvedVersion, latestVersion } = versionContext;

response.setHeader('X-API-Version', resolvedVersion);
response.setHeader('X-API-Latest-Version', latestVersion);
response.setHeader('X-API-Version-Status', definition.status);

if (this.apiVersionService.isDeprecated(definition)) {
response.setHeader('X-API-Deprecation', 'true');
response.setHeader(
'Warning',
`299 - "${this.apiVersionService.buildDeprecationNotice(definition, versionContext.source)}"`,
);
}

if (definition.sunsetDate) {
response.setHeader('Sunset', new Date(definition.sunsetDate).toUTCString());
}

if (definition.successorVersion) {
response.setHeader(
'Link',
`</api/docs/migrations/v${definition.version}-to-v${definition.successorVersion}>; rel="successor-version"`,
);
}
}

return next.handle();
}
}
Loading
Loading