Skip to content

Commit b65cf5a

Browse files
Merge pull request #1 from LCP-team/dev-gabriel
feat: add ai chat gateway with audit logging and upstream protection
2 parents df89db8 + 07c3d5d commit b65cf5a

9 files changed

Lines changed: 890 additions & 1 deletion

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ DATABASE_URL=postgresql://user:password@localhost:5432/lifelinesproject
33
COOKIE_SECRET=change-me-to-a-long-random-string
44
JWT_SECRET=change-me-to-a-long-random-string
55
JWT_EXPIRES_IN=7d
6+
CLIENT_URL=http://localhost:5173
7+
8+
AI_SERVICE_BASE_URL=http://localhost:8000
9+
AI_SERVICE_TIMEOUT_MS=30000
10+
AI_SERVICE_HEALTH_PATH=/health
11+
AI_SERVICE_SESSION_START_PATH=/api/v2/session/start
12+
AI_SERVICE_MESSAGE_PATH=/api/v2/chat
13+
AI_SERVICE_SESSION_CLOSE_PATH=/api/v2/session/close
14+
AI_GATEWAY_SHARED_SECRET=change-me-shared-secret-for-lifelines-ai
15+
AI_CHAT_DEV_MODE=false
16+
AI_CHAT_DEV_USER_ID=local-ai-demo
17+
AI_CHAT_DEV_EMAIL=local-ai-demo@localhost
618

719
GOOGLE_CLIENT_ID=your-google-client-id
820
GOOGLE_CLIENT_SECRET=your-google-client-secret

docs/ai-chat-web-gateway.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# AI Chat Web Gateway
2+
3+
Tanggal: 2026-04-10
4+
5+
Dokumen ini menerjemahkan planning workspace yang sudah ada menjadi jalur implementasi konkret untuk chat AI di web.
6+
7+
## Existing Planning
8+
9+
Planning tingkat sprint sudah ada di workspace:
10+
11+
- `docs/mastertask.md`
12+
- `docs/sprint-plan-2026-04-08.md`
13+
14+
Ringkasnya, planning yang sudah ada sudah cukup untuk arah arsitektur, tetapi belum cukup detail untuk langsung coding end-to-end.
15+
16+
## Implemented Path
17+
18+
Vertical slice yang dipakai untuk implementasi awal:
19+
20+
1. `lifelinesproject-astro`
21+
- halaman baru `GET /ai-chat`
22+
- request selalu mengirim `X-Trace-Id`
23+
- UI menampilkan audit trail per aksi
24+
2. `lifelinesproject-api`
25+
- `GET /ai-chat/status`
26+
- `POST /ai-chat/session`
27+
- `POST /ai-chat/messages`
28+
- `DELETE /ai-chat/session/:sessionId`
29+
- API meneruskan trace id ke AI service
30+
- path upstream AI bisa dikonfigurasi via env supaya tetap menunjuk endpoint real di `lifelines-ai`
31+
- API menulis audit log JSON untuk request, response, timeout, dan error
32+
3. `lifelines-ai`
33+
- `POST /api/v2/session/start`
34+
- `POST /api/v2/chat`
35+
- `PUT /api/v2/session/close`
36+
- AI service ikut menulis audit log JSON dengan trace id yang sama
37+
- route `/api/*` sekarang hanya menerima request dari gateway `lifelinesproject-api` lewat shared secret header
38+
39+
## Audit Logging Contract
40+
41+
Setiap hop minimal membawa field berikut:
42+
43+
- `timestamp`
44+
- `component`
45+
- `event`
46+
- `trace_id`
47+
- `session_id`
48+
- `user_id_hash`
49+
- `latency_ms`
50+
- `message_length`
51+
- `reply_length`
52+
- `token_input_total`
53+
- `token_output_total`
54+
55+
## Current Assumptions
56+
57+
- frontend canonical untuk web chat adalah `lifelinesproject-astro`
58+
- auth web tetap lewat `lifelinesproject-api`
59+
- AI session state tetap disimpan di `lifelines-ai`
60+
- summary enrichment dari database API belum dihubungkan; untuk slice awal dikirim sebagai string kosong
61+
- endpoint default upstream yang dipakai gateway:
62+
- `GET /health`
63+
- `POST /api/v2/session/start`
64+
- `POST /api/v2/chat`
65+
- `PUT /api/v2/session/close`
66+
67+
## Security Boundary
68+
69+
- browser/web hanya boleh memanggil `lifelinesproject-api`
70+
- `lifelinesproject-api` meneruskan request ke `lifelines-ai` dengan header internal `X-Lifelines-Gateway-Key`
71+
- `lifelines-ai` menolak direct access ke semua route `/api/*` jika header shared secret tidak valid
72+
- `GET /health` tetap public untuk health check
73+
- `/telegram/*` tetap public karena dipakai webhook Telegram, dengan proteksi `TELEGRAM_WEBHOOK_SECRET`
74+
- route debug web `/dev/ai-chat` hanya aktif di local dev build saat `VITE_AI_CHAT_DEV_MODE=true`
75+
- mode bypass auth untuk AI chat web juga hanya aktif di local dev build, bukan di production build
76+
77+
## Update Log
78+
79+
- `2026-04-10` - vertical slice web -> api -> ai selesai, audit logging end-to-end aktif
80+
- `2026-04-14` - hardening ditambahkan supaya web/browser tidak bisa bypass gateway; akses ke `lifelines-ai` route `/api/*` sekarang wajib lewat `lifelinesproject-api`
81+
- `2026-04-15` - route debug `/dev/ai-chat` dijadikan dev-only; production build tidak lagi mengekspos halaman debug atau auth bypass untuk AI chat
82+
83+
## Next Expansion After This Slice
84+
85+
- persist transcript metadata di API untuk analytics / admin audit
86+
- enrich summary payload dari profile pengguna
87+
- tambah session list / history di web
88+
- tambah guard bisnis untuk kuota gratis vs berbayar

src/ai-chat/ai-chat.controller.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Headers,
7+
Param,
8+
Post,
9+
Req,
10+
UnauthorizedException,
11+
} from '@nestjs/common';
12+
import { ConfigService } from '@nestjs/config';
13+
import { JwtService } from '@nestjs/jwt';
14+
import type { Request } from 'express';
15+
import type { AuthUser } from '../auth/types/auth-user.type';
16+
import { AiChatService } from './ai-chat.service';
17+
import { SendAiChatMessageDto } from './dto/send-ai-chat-message.dto';
18+
import { StartAiChatSessionDto } from './dto/start-ai-chat-session.dto';
19+
20+
@Controller('ai-chat')
21+
export class AiChatController {
22+
constructor(
23+
private readonly aiChatService: AiChatService,
24+
private readonly configService: ConfigService,
25+
private readonly jwtService: JwtService,
26+
) {}
27+
28+
@Get('status')
29+
getStatus(@Headers('x-trace-id') traceId?: string) {
30+
return this.aiChatService.getStatus(traceId);
31+
}
32+
33+
@Post('session')
34+
startSession(
35+
@Req() req: Request,
36+
@Body() dto: StartAiChatSessionDto,
37+
@Headers('x-trace-id') traceId?: string,
38+
) {
39+
const user = this.resolveUser(req);
40+
return this.aiChatService.startSession(user, dto, traceId);
41+
}
42+
43+
@Post('messages')
44+
sendMessage(
45+
@Req() req: Request,
46+
@Body() dto: SendAiChatMessageDto,
47+
@Headers('x-trace-id') traceId?: string,
48+
) {
49+
const user = this.resolveUser(req);
50+
return this.aiChatService.sendMessage(user, dto, traceId);
51+
}
52+
53+
@Delete('session/:sessionId')
54+
closeSession(
55+
@Req() req: Request,
56+
@Param('sessionId') sessionId: string,
57+
@Headers('x-trace-id') traceId?: string,
58+
) {
59+
const user = this.resolveUser(req);
60+
return this.aiChatService.closeSession(user, sessionId, traceId);
61+
}
62+
63+
private resolveUser(req: Request): AuthUser {
64+
const token = this.readSignedCookie(req, 'access_token');
65+
66+
if (token) {
67+
try {
68+
const payload = this.jwtService.verify<{
69+
sub: string;
70+
email: string;
71+
role: AuthUser['role'];
72+
}>(token, {
73+
secret: this.configService.getOrThrow<string>('JWT_SECRET'),
74+
});
75+
76+
return {
77+
id: payload.sub,
78+
email: payload.email,
79+
role: payload.role,
80+
};
81+
} catch {
82+
if (!this.isDevModeEnabled()) {
83+
throw new UnauthorizedException('Invalid access token');
84+
}
85+
}
86+
}
87+
88+
if (this.isDevModeEnabled()) {
89+
return {
90+
id:
91+
this.configService.get<string>('AI_CHAT_DEV_USER_ID') ||
92+
'local-ai-demo',
93+
email:
94+
this.configService.get<string>('AI_CHAT_DEV_EMAIL') ||
95+
'local-ai-demo@localhost',
96+
role: null,
97+
};
98+
}
99+
100+
throw new UnauthorizedException('Sign in required');
101+
}
102+
103+
private readSignedCookie(
104+
req: Request,
105+
cookieName: string,
106+
): string | undefined {
107+
const signedCookies = req.signedCookies as
108+
| Record<string, string>
109+
| undefined;
110+
111+
return signedCookies?.[cookieName];
112+
}
113+
114+
private isDevModeEnabled(): boolean {
115+
const rawValue = (
116+
this.configService.get<string>('AI_CHAT_DEV_MODE') || ''
117+
).toLowerCase();
118+
119+
return ['1', 'true', 'yes', 'on'].includes(rawValue);
120+
}
121+
}

src/ai-chat/ai-chat.module.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { ConfigModule } from '@nestjs/config';
3+
import { JwtModule } from '@nestjs/jwt';
4+
import { AiChatController } from './ai-chat.controller';
5+
import { AiChatService } from './ai-chat.service';
6+
7+
@Module({
8+
imports: [ConfigModule, JwtModule.register({})],
9+
controllers: [AiChatController],
10+
providers: [AiChatService],
11+
exports: [AiChatService],
12+
})
13+
export class AiChatModule {}

0 commit comments

Comments
 (0)