Initialize environment configuration and enhance API logging

- Added a new .env file for environment variables including database and Redis configurations.
- Updated CLAUDE.md with hard rules for development practices.
- Enhanced package.json with new scripts for development and infrastructure management.
- Integrated Pino for structured logging in the API, replacing the default NestJS logger.
- Implemented OpenTelemetry for tracing and monitoring in the API.
- Added durationMinutes field to the Mission model in Prisma schema and created corresponding migration.
- Updated missions controller and service to handle mission duration and abandonment logic.
- Introduced new logger module for consistent logging across the application.
This commit is contained in:
Maurycy
2026-05-11 08:38:19 +00:00
parent 21f1a5319f
commit 0031ef0a8f
107 changed files with 3948 additions and 725 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "missions" ADD COLUMN "duration_minutes" SMALLINT NOT NULL DEFAULT 20;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -37,6 +37,7 @@ model Mission {
groupId String? @map("group_id") @db.Uuid
channelId String @map("channel_id")
difficulty Int @db.SmallInt
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
status String @default("active")
encounterLibraryVersion String @map("encounter_library_version")
startedAt DateTime @default(now()) @map("started_at")

View File

@@ -2,22 +2,22 @@ import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppLoggerModule } from './logger/logger.module';
import { MissionsModule } from './missions/missions.module';
import { PrismaModule } from './prisma/prisma.module';
import { TickEngineModule } from './tick-engine/tick-engine.module';
@Module({
imports: [
AppLoggerModule,
ScheduleModule.forRoot(),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 10000, limit: 30 }],
throttlers: [{ ttl: 1000, limit: 300 }],
}),
PrismaModule,
MissionsModule,
TickEngineModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}

View File

@@ -6,6 +6,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { createHmac } from 'crypto';
import { PinoLogger } from 'nestjs-pino';
export interface TwitchJwtPayload {
opaque_user_id: string;
@@ -17,6 +18,7 @@ export interface TwitchJwtPayload {
interface HttpRequest {
headers: Record<string, string | string[] | undefined>;
twitchClaims?: TwitchJwtPayload;
log?: { bindings: () => Record<string, unknown>; child: (b: Record<string, unknown>) => unknown };
}
export const TwitchClaims = createParamDecorator(
@@ -29,6 +31,10 @@ export const TwitchClaims = createParamDecorator(
@Injectable()
export class TwitchJwtGuard implements CanActivate {
constructor(private readonly logger: PinoLogger) {
this.logger.setContext(TwitchJwtGuard.name);
}
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<HttpRequest>();
const authHeader = req.headers['authorization'];
@@ -36,11 +42,52 @@ export class TwitchJwtGuard implements CanActivate {
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
const token = auth.slice(7);
const devClaims = tryDevBypass(token);
if (devClaims) {
req.twitchClaims = devClaims;
this.logger.warn({ msg: 'dev auth bypass active', opaqueUserId: devClaims.opaque_user_id });
this.logger.assign({ channelId: devClaims.channel_id, opaqueUserId: devClaims.opaque_user_id });
return true;
}
req.twitchClaims = verifyAndDecode(token);
// Bind Twitch identity into this request's pino logger so every subsequent
// log line in the request lifecycle carries channelId and opaqueUserId.
this.logger.assign({
channelId: req.twitchClaims.channel_id,
opaqueUserId: req.twitchClaims.opaque_user_id,
});
return true;
}
}
/**
* Accepts the fake dev JWT minted by TwitchAuthService.buildDevAuth() when
* NODE_ENV=development. Tokens are identified by their `.dev` signature and
* a UDEV-prefixed opaque_user_id. Fails loudly if called outside dev mode —
* this guard must NEVER run in production.
*/
function tryDevBypass(token: string): TwitchJwtPayload | null {
if (process.env['NODE_ENV'] !== 'development') return null;
const parts = token.split('.');
if (parts.length !== 3 || parts[2] !== 'dev') return null;
try {
const raw = Buffer.from(
parts[1].replace(/-/g, '+').replace(/_/g, '/'),
'base64'
).toString('utf8');
const payload = JSON.parse(raw) as TwitchJwtPayload;
if (!payload.opaque_user_id?.startsWith('UDEV')) return null;
return payload;
} catch {
return null;
}
}
function verifyAndDecode(token: string): TwitchJwtPayload {
const parts = token.split('.');
if (parts.length !== 3) throw new UnauthorizedException('Malformed JWT');

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
const isDev = process.env['NODE_ENV'] !== 'production';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env['LOG_LEVEL'] ?? (isDev ? 'debug' : 'info'),
// pino-pretty in dev; raw JSON in prod (consumed by log aggregators).
transport: isDev
? { target: 'pino-pretty', options: { colorize: true, singleLine: false } }
: undefined,
// Suppress /health probes from cluttering the log stream.
autoLogging: {
ignore: (req) => req.url?.includes('/health') ?? false,
},
serializers: {
req: (req) => ({ method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
},
}),
],
})
export class AppLoggerModule {}

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import type {
EncounterResult,
Mission,
@@ -21,16 +22,18 @@ const RECENT_LOG_MAX = 20;
@Injectable()
export class EncounterService {
private readonly logger = new Logger(EncounterService.name);
constructor(private readonly groupSynergy: GroupSynergyService) {}
constructor(
private readonly logger: PinoLogger,
private readonly groupSynergy: GroupSynergyService,
) {}
/**
* Resolves one tick for the given mission state.
* Returns the updated state, or null if the mission ended.
* channelId is threaded in from the TickService for structured log correlation.
*/
processTick(
current: NonNullable<MissionStateResponse>
current: NonNullable<MissionStateResponse>,
channelId: string | null,
): NonNullable<MissionStateResponse> {
const { mission, survivors } = current;
const tickIndex = mission.tickIndex + 1;
@@ -56,7 +59,7 @@ export class EncounterService {
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
survivor,
groupModifiers
groupModifiers,
);
const result = resolveEncounter({
@@ -81,21 +84,22 @@ export class EncounterService {
newLog.push({ ...result, logText: flavor });
this.logger.log({
message: 'tick resolved',
this.logger.info({
msg: 'tick resolved',
missionId: mission.id,
channelId: 'unknown',
channelId,
tickIndex,
survivorId: survivor.id,
encounterKey: encounter.key,
success: result.success,
seed: result.seed,
modifiersApplied: result.modifiersApplied.length,
});
const stateChange = result.survivorStateChange;
if (stateChange) {
updatedSurvivors = updatedSurvivors.map((s) =>
s.id === survivor.id ? { ...s, state: stateChange.to } : s
s.id === survivor.id ? { ...s, state: stateChange.to } : s,
);
updatedParticipants = updatedParticipants.map((p) => {
if (p.survivorId !== survivor.id) return p;
@@ -111,13 +115,13 @@ export class EncounterService {
const updatedMission = buildUpdatedMission(
mission,
updatedParticipants,
tickIndex
tickIndex,
);
const recentLog = [
...newLog,
...current.recentLog,
].slice(0, RECENT_LOG_MAX);
const recentLog = [...newLog, ...current.recentLog].slice(
0,
RECENT_LOG_MAX,
);
return {
mission: updatedMission,
@@ -134,14 +138,9 @@ function buildSeed(missionId: string, tickIndex: number): string {
function buildUpdatedMission(
mission: Mission,
participants: MissionParticipant[],
tickIndex: number
tickIndex: number,
): Mission {
const allSacrificed = participants.every(
(p) => p.state === 'sacrificed'
);
const allEscaped = participants.every(
(p) => p.state === 'active' || p.state === 'idle'
);
const allSacrificed = participants.every((p) => p.state === 'sacrificed');
let status = mission.status;
let endedAt = mission.endedAt;
@@ -149,15 +148,14 @@ function buildUpdatedMission(
if (allSacrificed && mission.status === 'active') {
status = 'sacrifice';
endedAt = new Date().toISOString();
} else if (allEscaped && tickIndex >= 10 && mission.status === 'active') {
// Success after at least 10 ticks with all survivors still active
} else if (!allSacrificed && tickIndex >= mission.durationMinutes && mission.status === 'active') {
status = 'success';
endedAt = new Date().toISOString();
}
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
const nextTickAt = new Date(
Date.now() + TICK_BASE_INTERVAL_MS + jitter
Date.now() + TICK_BASE_INTERVAL_MS + jitter,
).toISOString();
return {

View File

@@ -14,7 +14,11 @@ import {
MissionStateResponseSchema,
StartMissionRequestSchema,
} from '@fog-explorer/api-interfaces';
import { TwitchClaims, TwitchJwtGuard, TwitchJwtPayload } from '../auth/twitch-jwt.guard';
import {
TwitchClaims,
TwitchJwtGuard,
TwitchJwtPayload,
} from '../auth/twitch-jwt.guard';
import { MissionStoreService } from './mission-store.service';
import { MissionsService } from './missions.service';
@@ -23,28 +27,39 @@ import { MissionsService } from './missions.service';
export class MissionsController {
constructor(
private readonly store: MissionStoreService,
private readonly missions: MissionsService
private readonly missions: MissionsService,
) {}
@Get('state')
@Throttle({ default: { limit: 10, ttl: 10000 } })
@Throttle({ default: { limit: 100, ttl: 1000 } })
async getState(
@TwitchClaims() claims: TwitchJwtPayload
@TwitchClaims() claims: TwitchJwtPayload,
): Promise<MissionStateResponse> {
const state = await this.store.getStateForChannel(claims.channel_id);
return MissionStateResponseSchema.parse(state);
}
@Post('abandon')
@HttpCode(HttpStatus.NO_CONTENT)
async abandonMission(
@TwitchClaims() claims: TwitchJwtPayload,
): Promise<void> {
if (!claims.opaque_user_id.startsWith('U')) {
throw new ForbiddenException('Anonymous viewers cannot abandon missions');
}
await this.missions.abandonMission(claims);
}
@Post('start')
@HttpCode(HttpStatus.CREATED)
async startMission(
@TwitchClaims() claims: TwitchJwtPayload,
@Body() body: unknown
@Body() body: unknown,
): Promise<NonNullable<MissionStateResponse>> {
if (!claims.opaque_user_id.startsWith('U')) {
throw new ForbiddenException('Anonymous viewers cannot start missions');
}
const { difficulty } = StartMissionRequestSchema.parse(body);
return this.missions.startMission(claims, difficulty);
const { difficulty, durationMinutes, characterName } = StartMissionRequestSchema.parse(body);
return this.missions.startMission(claims, difficulty, durationMinutes, characterName);
}
}

View File

@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { TwitchJwtGuard } from '../auth/twitch-jwt.guard';
import { RedisModule } from '../redis/redis.module';
import { EncounterService } from './encounter.service';
import { GroupSynergyService } from './group-synergy.service';
@@ -7,9 +9,10 @@ import { MissionsController } from './missions.controller';
import { MissionsService } from './missions.service';
@Module({
imports: [RedisModule],
imports: [RedisModule, LoggerModule],
controllers: [MissionsController],
providers: [
TwitchJwtGuard,
MissionStoreService,
MissionsService,
EncounterService,

View File

@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
Mission,
MissionDurationMinutes,
MissionStateResponse,
Survivor,
SurvivorStats,
@@ -22,7 +27,9 @@ export class MissionsService {
async startMission(
claims: TwitchJwtPayload,
difficulty: number
difficulty: number,
durationMinutes: MissionDurationMinutes,
characterName?: string,
): Promise<NonNullable<MissionStateResponse>> {
const missionId = crypto.randomUUID();
const survivorId = crypto.randomUUID();
@@ -30,6 +37,7 @@ export class MissionsService {
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
const nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter);
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
// Upsert user and create survivor + mission in one transaction.
await this.prisma.$transaction(async (tx) => {
@@ -44,7 +52,7 @@ export class MissionsService {
id: survivorId,
userId: user.id,
channelId: claims.channel_id,
name: defaultName(claims.opaque_user_id),
name: survivorName,
state: 'active',
stats,
perkSlots: [],
@@ -56,6 +64,7 @@ export class MissionsService {
id: missionId,
channelId: claims.channel_id,
difficulty,
durationMinutes,
status: 'active',
encounterLibraryVersion: getLibraryVersion(),
nextTickAt,
@@ -75,7 +84,7 @@ export class MissionsService {
id: survivorId,
opaqueUserId: claims.opaque_user_id,
channelId: claims.channel_id,
name: defaultName(claims.opaque_user_id),
name: survivorName,
state: 'active',
stats,
perkSlots: [],
@@ -87,6 +96,7 @@ export class MissionsService {
groupId: null,
participants: [{ survivorId, state: 'active', hookCount: 0 }],
difficulty,
durationMinutes,
status: 'active',
encounterLibraryVersion: getLibraryVersion(),
nextTickAt: nextTickAt.toISOString(),
@@ -107,6 +117,38 @@ export class MissionsService {
return state;
}
async abandonMission(claims: TwitchJwtPayload): Promise<void> {
const state = await this.store.getStateForChannel(claims.channel_id);
if (!state) {
throw new NotFoundException('No active mission');
}
if (state.mission.status !== 'active') {
throw new ForbiddenException('Mission is not active');
}
const isParticipant = state.survivors.some(
(s) => s.opaqueUserId === claims.opaque_user_id,
);
if (!isParticipant) {
throw new ForbiddenException('Not a participant in this mission');
}
const now = new Date().toISOString();
const updated: NonNullable<MissionStateResponse> = {
...state,
mission: { ...state.mission, status: 'abandoned', endedAt: now },
};
await this.prisma.mission.update({
where: { id: state.mission.id },
data: { status: 'abandoned', endedAt: new Date(now) },
});
await this.store.setActiveMission(updated);
await this.store.removeMissionFromQueue(state.mission.id);
}
}
function defaultName(opaqueUserId: string): string {

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { MissionsModule } from '../missions/missions.module';
import { TickService } from './tick.service';
import { TwitchPubSubService } from './twitch-pubsub.service';
@Module({
imports: [MissionsModule],
imports: [MissionsModule, LoggerModule],
providers: [TickService, TwitchPubSubService],
})
export class TickEngineModule {}

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service';
import { EncounterService } from '../missions/encounter.service';
import { MissionStoreService } from '../missions/mission-store.service';
@@ -7,13 +8,12 @@ import { TwitchPubSubService } from './twitch-pubsub.service';
@Injectable()
export class TickService {
private readonly logger = new Logger(TickService.name);
constructor(
private readonly logger: PinoLogger,
private readonly store: MissionStoreService,
private readonly encounters: EncounterService,
private readonly prisma: PrismaService,
private readonly pubsub: TwitchPubSubService
private readonly pubsub: TwitchPubSubService,
) {}
@Cron(CronExpression.EVERY_10_SECONDS)
@@ -22,7 +22,7 @@ export class TickService {
const dueMissionIds = await this.store.getDueMissionIds(now);
await Promise.allSettled(
dueMissionIds.map((id) => this.processMission(id))
dueMissionIds.map((id) => this.processMission(id)),
);
}
@@ -30,6 +30,10 @@ export class TickService {
const token = await this.store.acquireLock(missionId);
if (!token) return;
// Fetch channelId up-front so every log line in this mission carries it.
const channelId = await this.getChannelId(missionId);
const log = this.logger.logger.child({ missionId, channelId });
try {
const state = await this.store.getActiveMission(missionId);
if (!state || state.mission.status !== 'active') {
@@ -37,10 +41,10 @@ export class TickService {
return;
}
const updated = this.encounters.processTick(state);
const updated = this.encounters.processTick(state, channelId);
const newLogs = updated.recentLog.slice(
0,
updated.recentLog.length - state.recentLog.length
updated.recentLog.length - state.recentLog.length,
);
// Write to Postgres in a transaction.
@@ -51,18 +55,23 @@ export class TickService {
tickIndex: updated.mission.tickIndex,
nextTickAt: new Date(updated.mission.nextTickAt),
status: updated.mission.status,
endedAt: updated.mission.endedAt ? new Date(updated.mission.endedAt) : null,
endedAt: updated.mission.endedAt
? new Date(updated.mission.endedAt)
: null,
},
});
for (const survivor of updated.survivors) {
const participant = updated.mission.participants.find(
(p) => p.survivorId === survivor.id
(p) => p.survivorId === survivor.id,
);
if (!participant) continue;
await tx.missionParticipant.updateMany({
where: { missionId, survivorId: survivor.id },
data: { state: participant.state, hookCount: participant.hookCount },
data: {
state: participant.state,
hookCount: participant.hookCount,
},
});
await tx.survivor.update({
where: { id: survivor.id },
@@ -72,14 +81,14 @@ export class TickService {
if (newLogs.length > 0) {
await tx.missionLog.createMany({
data: newLogs.map((log) => ({
data: newLogs.map((entry) => ({
id: crypto.randomUUID(),
missionId,
tickIndex: log.tickIndex,
encounterKey: log.encounterKey,
renderedText: log.logText,
seed: log.seed,
modifiersApplied: log.modifiersApplied,
tickIndex: entry.tickIndex,
encounterKey: entry.encounterKey,
renderedText: entry.logText,
seed: entry.seed,
modifiersApplied: entry.modifiersApplied,
})),
});
}
@@ -93,19 +102,16 @@ export class TickService {
await this.store.scheduleTick(missionId, nextMs);
} else {
await this.store.removeMissionFromQueue(missionId);
this.logger.log({
message: 'mission ended',
missionId,
log.info({
msg: 'mission ended',
status: updated.mission.status,
tickIndex: updated.mission.tickIndex,
});
}
await this.pubsub.broadcast(state.mission.participants[0]
? await this.getChannelId(missionId)
: null, updated);
await this.pubsub.broadcast(channelId, updated);
} catch (err) {
this.logger.error({ message: 'tick failed', missionId, err });
log.error({ msg: 'tick failed', err });
} finally {
await this.store.releaseLock(missionId, token);
}

View File

@@ -0,0 +1 @@
-

View File

@@ -1,20 +1,33 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/
// OTel must be initialised before any other requires — do not move this line.
import './tracing';
import { Logger } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
if (process.env['NODE_ENV'] === 'production' && process.env['DEV_AUTH_BYPASS']) {
throw new Error('DEV_AUTH_BYPASS must not be set in production');
}
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
const allowedOrigins =
process.env['NODE_ENV'] === 'development'
? ['http://localhost:4200', /https:\/\/.*\.ext-twitch\.tv$/]
: [/https:\/\/.*\.ext-twitch\.tv$/];
app.enableCors({ origin: allowedOrigins, credentials: true });
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;
const port = process.env['PORT'] ?? 3000;
await app.listen(port, '0.0.0.0');
Logger.log(
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
app.get(Logger).log(
`Application running on http://localhost:${port}/${globalPrefix}`,
'bootstrap'
);
}

30
apps/api/src/tracing.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* OpenTelemetry bootstrap — must be imported before any other app module.
* main.ts imports this as its first line.
*/
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
const exporter = process.env['OTEL_EXPORTER_OTLP_ENDPOINT']
? new OTLPTraceExporter() // reads OTEL_EXPORTER_OTLP_ENDPOINT from env
: new ConsoleSpanExporter();
const sdk = new NodeSDK({
resource: resourceFromAttributes({ 'service.name': 'fog-expedition-api' }),
traceExporter: exporter,
instrumentations: [
getNodeAutoInstrumentations({
// fs instrumentation is too noisy for a server with many module loads.
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});