Enable auth-service/api-gateway on production, build their images in CI

This commit is contained in:
2026-08-18 14:11:00 +07:00
parent e5afedfa2f
commit b68005be1c
70 changed files with 6781 additions and 263 deletions
+8 -5
View File
@@ -1,9 +1,12 @@
"""Point the k3s practice cluster's ArgoCD Application at a freshly-built """Point the k3s ArgoCD Application at an image tag, then trigger an
image tag, then trigger an immediate sync. immediate sync.
Only touches `medical-chatbot-app` on the practice cluster `medical-chatbot-app` (argocd.realvuxbaro.me) is the production Application —
(argocd.realvuxbaro.me). Never touches production — the EC2 Compose it serves both realvuxbaro.me and readytochat.realvuxbaro.me — since the
deployment isn't ArgoCD-managed at all. 2026-08-17 cutover. This script does not distinguish "forward" from
"rollback": it points the Application at whatever IMAGE_TAG it is given and
syncs, so `rollback-k3s.yml` reuses it unchanged with an older tag rather than
duplicating this logic.
Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD, IMAGE_TAG. Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD, IMAGE_TAG.
""" """
+102
View File
@@ -0,0 +1,102 @@
"""Block until the k3s ArgoCD Application is running EXPECT_TAG and is
Synced + Healthy, or time out.
Companion to `sync_practice_argocd.py`, which fires a sync and returns
immediately (it must, since ArgoCD's own selfHeal can race an explicit sync
call — see its comment). A rollback caller needs the opposite guarantee: do
not report success until the rollout has actually landed. Kept as a separate
script rather than merged into that one, since the normal forward-deploy path
in `build-practice-images.yml` confirms liveness a different way (an HTTP
smoke check against readytochat) and doesn't need this blocking behaviour.
Required env: ARGOCD_PRACTICE_URL, ARGOCD_PRACTICE_PASSWORD, EXPECT_TAG.
Optional env: APP_NAME (default medical-chatbot-app), TIMEOUT_SECONDS
(default 300), POLL_INTERVAL_SECONDS (default 10).
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
IMAGES = ("vsf-duocthu-ai-service", "vsf-duocthu-web")
def call(base: str, method: str, path: str, token: str | None = None, body=None):
req = urllib.request.Request(
f"{base}{path}",
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Content-Type": "application/json"},
)
if token:
req.add_header("Authorization", f"Bearer {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
print(f"{method} {path} -> {exc.code}: {exc.read().decode(errors='replace')}", file=sys.stderr)
raise
def deployed_tag(values: str, image: str) -> str | None:
# Same shape sync_practice_argocd.py writes: a `repository:` line
# immediately followed by its `tag:` line. Reading it back with the
# mirror-image regex, rather than a looser substring check, means this
# can't be fooled by the tag also appearing in a comment or another
# image's block.
match = re.search(
rf"repository:\s*ghcr\.io/baovu2k4/{re.escape(image)}\s*\n\s*tag:\s*(\S+)",
values,
)
return match.group(1) if match else None
def main() -> int:
base = os.environ["ARGOCD_PRACTICE_URL"].rstrip("/")
password = os.environ["ARGOCD_PRACTICE_PASSWORD"]
expect_tag = os.environ["EXPECT_TAG"]
app_name = os.environ.get("APP_NAME", "medical-chatbot-app")
timeout_seconds = int(os.environ.get("TIMEOUT_SECONDS", "300"))
poll_interval = int(os.environ.get("POLL_INTERVAL_SECONDS", "10"))
session = call(base, "POST", "/api/v1/session", body={"username": "admin", "password": password})
token = session["token"]
deadline = time.monotonic() + timeout_seconds
last_state = "no poll yet"
while time.monotonic() < deadline:
app = call(base, "GET", f"/api/v1/applications/{app_name}", token=token)
values = app["spec"]["source"]["helm"]["values"]
tags = {image: deployed_tag(values, image) for image in IMAGES}
sync_status = app.get("status", {}).get("sync", {}).get("status")
health_status = app.get("status", {}).get("health", {}).get("status")
last_state = f"tags={tags} sync={sync_status} health={health_status}"
if (
all(tag == expect_tag for tag in tags.values())
and sync_status == "Synced"
and health_status == "Healthy"
):
print(f"{app_name} is on {expect_tag}, Synced, Healthy: {last_state}")
return 0
print(f"waiting: {last_state}")
time.sleep(poll_interval)
print(
f"Timed out after {timeout_seconds}s waiting for {app_name} to reach "
f"{expect_tag}/Synced/Healthy. Last observed: {last_state}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -18,6 +18,8 @@ on:
paths: paths:
- apps/ai-service/** - apps/ai-service/**
- apps/web/** - apps/web/**
- apps/auth-service/**
- apps/api-gateway/**
- packages/** - packages/**
- ingestion/data/verified/drug_entities.json - ingestion/data/verified/drug_entities.json
- .github/workflows/build-practice-images.yml - .github/workflows/build-practice-images.yml
@@ -66,6 +68,33 @@ jobs:
cache-from: type=gha,scope=practice-web cache-from: type=gha,scope=practice-web
cache-to: type=gha,mode=max,scope=practice-web cache-to: type=gha,mode=max,scope=practice-web
- name: Build and push auth-service
uses: docker/build-push-action@v6
with:
context: .
file: apps/auth-service/Dockerfile
push: true
tags: ghcr.io/baovu2k4/vsf-duocthu-auth-service:${{ github.sha }}
cache-from: type=gha,scope=practice-auth-service
cache-to: type=gha,mode=max,scope=practice-auth-service
- name: Build and push api-gateway
uses: docker/build-push-action@v6
with:
context: .
file: apps/api-gateway/Dockerfile
push: true
tags: ghcr.io/baovu2k4/vsf-duocthu-api-gateway:${{ github.sha }}
cache-from: type=gha,scope=practice-api-gateway
cache-to: type=gha,mode=max,scope=practice-api-gateway
# auth-service/api-gateway are NOT in sync_practice_argocd.py's IMAGES
# tuple yet — that script fails the whole run if a tag it expects to
# rewrite isn't already present inline on the Application, so extending
# it must wait until someone has added `authService.image` /
# `apiGateway.image` blocks to the live Application by hand (see
# infra/helm/medical-chatbot/values-production.yaml). Until then, these
# two images are pushed here but must be repointed manually.
- name: Point the practice ArgoCD Application at the new images - name: Point the practice ArgoCD Application at the new images
env: env:
ARGOCD_PRACTICE_URL: ${{ secrets.ARGOCD_PRACTICE_URL }} ARGOCD_PRACTICE_URL: ${{ secrets.ARGOCD_PRACTICE_URL }}
+25
View File
@@ -102,3 +102,28 @@ jobs:
- name: Build - name: Build
run: pnpm --filter @duoc-thu/web build run: pnpm --filter @duoc-thu/web build
auth-and-gateway:
name: auth-service + api-gateway — lint + build + test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Enable pnpm
run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm --filter @duoc-thu/auth-service --filter @duoc-thu/api-gateway lint
- name: Build
run: pnpm --filter @duoc-thu/auth-service --filter @duoc-thu/api-gateway build
- name: Test
run: pnpm --filter @duoc-thu/auth-service --filter @duoc-thu/api-gateway test
+85
View File
@@ -0,0 +1,85 @@
name: Rollback k3s production
# One-command escape hatch for medical-chatbot-app (ArgoCD, k3s) — the
# Application build-practice-images.yml normally advances, and the same one
# serving realvuxbaro.me since the 2026-08-17 cutover. This workflow does not
# build anything: it only repoints the Application at an OLDER image tag that
# a previous build-practice-images.yml run already pushed to GHCR, using the
# exact same sync_practice_argocd.py logic that workflow uses to move
# forward — a rollback is just that script pointed backward.
#
# Does not touch the Compose EC2 (stopped 2026-08-18, no CI/CD path left —
# see docs/operations.md).
on:
workflow_dispatch:
inputs:
target_sha:
description: >-
Commit SHA to roll back to. Must have a successful "Build and sync
k3s images" run (check: gh run list --workflow=build-practice-images.yml).
required: true
concurrency:
# Same group as build-practice-images.yml: a rollback and a forward deploy
# must never race to repoint the same Application at the same time.
group: practice-images
cancel-in-progress: false
jobs:
rollback:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Fails fast and clearly on the most likely operator mistake: a typo'd
# or never-built SHA, rather than that surfacing later as a confusing
# ArgoCD/pod-level image pull failure.
- name: Confirm images exist for target_sha
run: |
set -eu
docker buildx imagetools inspect "ghcr.io/baovu2k4/vsf-duocthu-ai-service:${{ inputs.target_sha }}" > /dev/null
docker buildx imagetools inspect "ghcr.io/baovu2k4/vsf-duocthu-web:${{ inputs.target_sha }}" > /dev/null
- name: Point medical-chatbot-app at target_sha
env:
ARGOCD_PRACTICE_URL: ${{ secrets.ARGOCD_PRACTICE_URL }}
ARGOCD_PRACTICE_PASSWORD: ${{ secrets.ARGOCD_PRACTICE_PASSWORD }}
IMAGE_TAG: ${{ inputs.target_sha }}
run: python3 .github/scripts/sync_practice_argocd.py
# Blocks until the rollout has actually landed, not just until the sync
# call returned — sync_practice_argocd.py deliberately doesn't wait
# (see its own comment on the selfHeal race), so a rollback specifically
# needs this extra confirmation before it can claim success.
- name: Wait for the Application to be Synced, Healthy, and on target_sha
env:
ARGOCD_PRACTICE_URL: ${{ secrets.ARGOCD_PRACTICE_URL }}
ARGOCD_PRACTICE_PASSWORD: ${{ secrets.ARGOCD_PRACTICE_PASSWORD }}
EXPECT_TAG: ${{ inputs.target_sha }}
run: python3 .github/scripts/wait_for_argocd_sync.py
- name: Confirm realvuxbaro.me is serving the rolled-back build
run: |
for attempt in $(seq 1 18); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
"https://realvuxbaro.me/api/history?conversation_id=rollback-smoke-${{ inputs.target_sha }}")
if [ "$code" = "200" ]; then
echo "realvuxbaro.me is live on ${{ inputs.target_sha }}"
exit 0
fi
sleep 10
done
echo "realvuxbaro.me did not respond healthy within 3 minutes after rollback"
exit 1
+62 -67
View File
@@ -12,25 +12,30 @@ The former numbered `0029` material and historical plans are retained in
there until reviewed. See the canonical there until reviewed. See the canonical
[documentation policy](docs/documentation-policy.md) for source precedence. [documentation policy](docs/documentation-policy.md) for source precedence.
> **Status** (2026-08-11): **live in production at > **Status** (2026-08-18): **live in production at
> [realvuxbaro.me](https://realvuxbaro.me)** — a real RAG chatbot over the > [realvuxbaro.me](https://realvuxbaro.me)**, running on **k3s + ArgoCD**
> whole formulary, not a scaffold. What exists and what does not: > since the 2026-08-17 cutover — a real RAG chatbot over the whole
> formulary, not a scaffold. What exists and what does not:
> >
> | Part | State | > | Part | State |
> |---|---| > |---|---|
> | `ingestion/` | Done — 15,100 chunks embedded and loaded into Qdrant `duocthu_v1` | > | `ingestion/` | Done — 15,100 chunks embedded and loaded into Qdrant `duocthu_v1` |
> | `apps/ai-service/` | Done — live grounded RAG (retrieval, generation, grounding, abstention, citations, traces) | > | `apps/ai-service/` | Done — live grounded RAG (retrieval, generation, grounding, abstention, citations, traces) |
> | `apps/web/` | Done — chat UI with citation/evidence panel | > | `apps/web/` | Done — chat UI with citation/evidence panel, optional login |
> | `apps/api-gateway`, `auth-service`, `user-service`, `chat-service` | **Not built** — `README.md` + `package.json` only | > | `apps/auth-service`, `apps/api-gateway` | **Built** (real NestJS: register/login/JWT, `/auth/*` proxy) but **disabled by default** in the Helm chart — not yet live on production. Seed accounts (`admin`/`demo`) use placeholder passwords not safe to expose publicly as-is |
> | `apps/user-service`, `apps/chat-service` | **Not built** — `README.md` + `package.json` only |
> | `apps/mobile/` | **Not built** — reserved | > | `apps/mobile/` | **Not built** — reserved |
> | `infra/docker/` | Done — production runs Compose, including the Prometheus/Grafana/Tempo observability overlay | > | `infra/docker/` | Compose is **stopped** (was production through 2026-08-17); still used for local dev (Postgres/Qdrant/observability), not for deploying anywhere |
> | `infra/helm/medical-chatbot/` | Built and validated as an offline migration kit; **not deployed** to Docker Desktop, k3s or ArgoCD | > | `infra/helm/medical-chatbot/` | **This is production.** Two ArgoCD Applications, `medical-chatbot-app` and `medical-chatbot-data`, both tracked in `infra/argocd/applications/` |
> | `infra/k8s`, `terraform`, `argocd` | **Not built yet** — still the target (ADR 0002), not abandoned: the plan is the team's self-hosted Gitea + ArgoCD; the current EC2/Compose setup is an interim stopgap | > | Team's Gitea + ArgoCD (ADR 0002) | **Still not started**, not abandoned the personal k3s/ArgoCD instance above is a separate, owner-operated cluster, not the team's shared infrastructure |
> >
> Because the gateway and auth services do not exist, `apps/web` talks > `apps/web`'s chat/history/feedback/sections routes still call
> **directly** to `apps/ai-service`; there is no authentication layer. See > `apps/ai-service` **directly** — auth-service/api-gateway exist but chat
> traffic doesn't route through the gateway yet. Login is optional and only
> gates `/admin`; anonymous chat is unaffected. See
> [canonical architecture document](docs/architecture.md) for the implemented > [canonical architecture document](docs/architecture.md) for the implemented
> topology and the explicit status of current, scaffolded and target components. > topology (not yet updated for this — verify against code, not that doc,
> until it is).
## Directory map ## Directory map
@@ -56,8 +61,9 @@ docs-legacy/ raw notes, historical plans and ADRs pending review
## Prerequisites ## Prerequisites
- Node.js + pnpm (JS workspace: `apps/web`, `packages/*`; the NestJS service - Node.js + pnpm (JS workspace: `apps/web`, `apps/auth-service`,
directories are unbuilt placeholders) `apps/api-gateway`, `packages/*`; `apps/user-service`/`apps/chat-service`
are still unbuilt placeholders)
- Python 3.11+ (`apps/ai-service`, `ingestion`) - Python 3.11+ (`apps/ai-service`, `ingestion`)
- Docker (local Postgres + Qdrant via `infra/docker/docker-compose.yml`) - Docker (local Postgres + Qdrant via `infra/docker/docker-compose.yml`)
- AWS credentials with Bedrock invoke permission, for anything that generates - AWS credentials with Bedrock invoke permission, for anything that generates
@@ -143,62 +149,51 @@ Local endpoints:
| Tempo | `http://localhost:3200` | Trace backend; normally queried through Grafana | | Tempo | `http://localhost:3200` | Trace backend; normally queried through Grafana |
| ai-service metrics | `http://localhost:8079/metrics` | Raw OpenMetrics output when ai-service runs on port 8079 | | ai-service metrics | `http://localhost:8079/metrics` | Raw OpenMetrics output when ai-service runs on port 8079 |
For the existing EC2 Compose deployment, the optional overlay is Grafana is served through the same k3s ingress as the app, at
`infra/docker/docker-compose.observability.yml`. It leaves `https://realvuxbaro.me/grafana/`. Anonymous access is on but demoted to
`docker-compose.prod.yml` unchanged. A deployment, when explicitly approved, **Viewer** (dashboards load with no login; write actions need the admin
uses both files: account). Prometheus has no public URL — use **Grafana -> Explore ->
Prometheus** for PromQL, or **Explore -> Tempo** with a returned
```powershell `X-Trace-ID` to investigate a slow request.
docker compose `
-f infra/docker/docker-compose.prod.yml `
-f infra/docker/docker-compose.observability.yml `
up -d
```
Grafana is available directly through Caddy and the existing production TLS
certificate at `https://realvuxbaro.me/grafana/`. Anonymous access is disabled;
sign in with the Grafana admin account. A dedicated
`grafana.realvuxbaro.me` hostname can replace this path after its Namecheap A
record exists.
Grafana and Prometheus are also bound to EC2 loopback only. This keeps both SSH
fallbacks available without exposing their native ports to the Internet:
```powershell
ssh `
-L 3002:127.0.0.1:3002 `
-L 9090:127.0.0.1:9090 `
<ssh-user>@52.0.158.61
```
Keep that session open and use `http://localhost:3002/grafana/` for Grafana or
`http://localhost:9090` for the raw Prometheus UI. The same Grafana account is
used through both the public HTTPS path and the SSH tunnel. The production
password lives in the GitHub Actions secret `GRAFANA_ADMIN_PASSWORD`; do not use
the Compose fallback password in production.
Prometheus intentionally has no public URL. Normally use
**Grafana -> Explore -> Prometheus**; use its SSH tunnel only for low-level
target or PromQL diagnostics. To investigate a slow request, open the
request-latency panel, follow its exemplar/trace link, or paste the returned
`X-Trace-ID` into **Explore -> Tempo**.
## Production ## Production
Live at [realvuxbaro.me](https://realvuxbaro.me): a single EC2 `t3.large` Live at [realvuxbaro.me](https://realvuxbaro.me), running on **k3s +
running `infra/docker/docker-compose.prod.yml` (postgres, qdrant, ai-service, ArgoCD** (a personal, owner-operated cluster — not the team's shared
web, Caddy for automatic Let's Encrypt TLS). Bedrock is reached through an IAM infrastructure) since the 2026-08-17 cutover. Two ArgoCD Applications:
instance role — there are no long-lived AWS keys on the box or in any env file. `medical-chatbot-app` (ai-service, web, observability) and
`medical-chatbot-data` (PostgreSQL, Qdrant — a separate release so an app
redeploy or prune can never touch persistent data). Both are tracked in
`infra/argocd/applications/`; `infra/helm/medical-chatbot/` is the chart both
render from. Bedrock is reached through an IAM instance role — no long-lived
AWS keys on the box or in any manifest.
Pushing to `master` deploys: `.github/workflows/deploy.yml` SSHes in, resets to Two things deploy independently:
the pushed commit, rebuilds `ai-service`/`web`, reconciles the observability
containers, reloads Caddy, runs migrations and verifies health, metrics and an
exact request trace. Postgres and Qdrant data survive deploys because they live
in named volumes rather than the containers.
This is **interim infrastructure**, not the end state. The intended target is - **App code** (`apps/ai-service/**`, `apps/web/**`, `packages/**`) — a push
still the team's self-hosted **Gitea** (company domain) plus their **ArgoCD** to `master` triggers `.github/workflows/build-practice-images.yml`, which
instance, per `docs-legacy/adr/0002-argocd-gitops.md` — that work is *not started*, builds and pushes GHCR images tagged by commit SHA, then repoints
not cancelled. Until it is deliberately started, the project stays on private `medical-chatbot-app` at the new tag. `ci.yml` runs in parallel and does
GitHub, and the team's existing `git.vinmec.tech/ai-team/gitops` repository is **not** gate this — a red test suite does not block a deploy.
reference-only: never push this project into it. - **Chart/config** (`infra/helm/**`) — `helm-chart.yml` lints and asserts
render invariants on the PR; once merged, ArgoCD's own `selfHeal` picks up
the change automatically. No CI step applies it directly.
Rollback is `.github/workflows/rollback-k3s.yml` (`workflow_dispatch`,
`target_sha`) — see `docs/operations.md` for the full runbook, including its
current gaps (no automated rollback for a config-only change, and the
Grafana admin password still lives inline on the ArgoCD Application rather
than in a real Kubernetes Secret).
The former EC2 Docker Compose deployment (`i-039fc8f6102467a54`) is
**stopped**, not deleted — see `docs/operations.md` if it's ever needed as a
manual DNS fallback again, though its corpus/schema will drift further out
of date the longer it stays off.
The team's self-hosted **Gitea** + **ArgoCD** (per
`docs-legacy/adr/0002-argocd-gitops.md`) remains the longer-term target for
this project and is **still not started** — not abandoned, just a separate
decision from the personal-cluster cutover above. Until it is deliberately
started, the project stays on private GitHub, and the team's existing
`git.vinmec.tech/ai-team/gitops` repository is reference-only: never push
this project into it.
+5
View File
@@ -0,0 +1,5 @@
# apps/api-gateway configuration. Copy to `.env` and edit for local dev:
# cp apps/api-gateway/.env.example apps/api-gateway/.env
PORT=3000
AUTH_SERVICE_URL=http://localhost:3010
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../packages/config/eslint-preset/index.js"
}
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api-gateway/package.json apps/api-gateway/package.json
COPY packages/config/package.json packages/config/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages/ packages/
COPY apps/api-gateway/ apps/api-gateway/
RUN pnpm --filter @duoc-thu/api-gateway build
FROM base AS runtime
ENV NODE_ENV=production
COPY --from=build /repo /repo
WORKDIR /repo/apps/api-gateway
EXPOSE 3000
CMD ["node", "dist/main.js"]
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testMatch: ["<rootDir>/test/**/*.spec.ts"],
};
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+30 -1
View File
@@ -1,5 +1,34 @@
{ {
"name": "@duoc-thu/api-gateway", "name": "@duoc-thu/api-gateway",
"private": true, "private": true,
"version": "0.0.0" "version": "0.0.0",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\" --max-warnings=0",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@duoc-thu/config": "workspace:*",
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
} }
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { ProxyModule } from "./proxy/proxy.module";
@Module({
imports: [ProxyModule],
})
export class AppModule {}
+11
View File
@@ -0,0 +1,11 @@
export interface Settings {
port: number;
authServiceUrl: string;
}
export function loadSettings(): Settings {
return {
port: Number(process.env.PORT ?? 3000),
authServiceUrl: process.env.AUTH_SERVICE_URL ?? "http://localhost:3010",
};
}
+12
View File
@@ -0,0 +1,12 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { loadSettings } from "./config";
async function bootstrap() {
const settings = loadSettings();
const app = await NestFactory.create(AppModule);
await app.listen(settings.port, "0.0.0.0");
}
bootstrap();
@@ -0,0 +1,38 @@
import { All, Controller, Req, Res } from "@nestjs/common";
import type { Request, Response } from "express";
import { loadSettings } from "../config";
const settings = loadSettings();
/**
* Thin proxy: forwards `/auth/*` to auth-service verbatim (method, body,
* `Authorization` header) and relays the response back unchanged. This is
* intentionally the ENTIRE gateway surface for this pass — chat/history/
* feedback/sections keep going straight from `apps/web`'s BFF to
* `ai-service`, not through here (see the plan this was built from). Growing
* this into the README's full "single public entry point" is future work,
* not done by accident just because this file exists.
*/
@Controller("auth")
export class AuthProxyController {
@All("*")
async proxy(@Req() req: Request, @Res() res: Response): Promise<void> {
const target = `${settings.authServiceUrl}${req.originalUrl}`;
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const upstream = await fetch(target, {
method: req.method,
headers: {
"Content-Type": "application/json",
...(req.headers.authorization
? { Authorization: req.headers.authorization }
: {}),
},
body: hasBody ? JSON.stringify(req.body ?? {}) : undefined,
});
const payload = await upstream.text();
res
.status(upstream.status)
.setHeader("Content-Type", upstream.headers.get("content-type") ?? "application/json")
.send(payload);
}
}
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { AuthProxyController } from "./auth-proxy.controller";
@Module({
controllers: [AuthProxyController],
})
export class ProxyModule {}
@@ -0,0 +1,81 @@
import "reflect-metadata";
describe("AuthProxyController", () => {
const originalFetch = global.fetch;
let AuthProxyController: typeof import("../src/proxy/auth-proxy.controller").AuthProxyController;
beforeAll(async () => {
// `../src/config.ts` reads this at module-import time, so it must be set
// before the dynamic import below, not in a plain top-of-file import.
process.env.AUTH_SERVICE_URL = "http://auth-service.test";
({ AuthProxyController } = await import("../src/proxy/auth-proxy.controller"));
});
afterAll(() => {
global.fetch = originalFetch;
});
it("forwards method, body, and Authorization header to auth-service and relays the response verbatim", async () => {
const mockFetch = jest.fn().mockResolvedValue({
status: 201,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ username: "demo", role: "user" }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const controller = new AuthProxyController();
const req = {
method: "POST",
originalUrl: "/auth/register",
headers: { authorization: "Bearer abc" },
body: { username: "demo", password: "1" },
} as unknown as import("express").Request;
const res = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as import("express").Response;
await controller.proxy(req, res);
expect(mockFetch).toHaveBeenCalledWith(
"http://auth-service.test/auth/register",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ Authorization: "Bearer abc" }),
body: JSON.stringify({ username: "demo", password: "1" }),
})
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.send).toHaveBeenCalledWith(JSON.stringify({ username: "demo", role: "user" }));
});
it("sends no body for a GET (e.g. /auth/me)", async () => {
const mockFetch = jest.fn().mockResolvedValue({
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ username: "demo", role: "user" }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const controller = new AuthProxyController();
const req = {
method: "GET",
originalUrl: "/auth/me",
headers: { authorization: "Bearer abc" },
body: {},
} as unknown as import("express").Request;
const res = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as import("express").Response;
await controller.proxy(req, res);
expect(mockFetch).toHaveBeenCalledWith(
"http://auth-service.test/auth/me",
expect.objectContaining({ method: "GET", body: undefined })
);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../packages/config/tsconfig-base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
# apps/auth-service configuration. Copy to `.env` and edit for local dev:
# cp apps/auth-service/.env.example apps/auth-service/.env
# Never commit a real JWT_SECRET.
PORT=3010
POSTGRES_DSN=postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu
# Required — the service refuses to start without one (see config.ts).
# Any long random string is fine for local dev; never reuse this value
# anywhere real.
JWT_SECRET=local-dev-only-change-me
JWT_EXPIRES_IN=12h
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../packages/config/eslint-preset/index.js"
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/auth-service/package.json apps/auth-service/package.json
COPY packages/shared-types/package.json packages/shared-types/package.json
COPY packages/config/package.json packages/config/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages/ packages/
COPY apps/auth-service/ apps/auth-service/
RUN pnpm --filter @duoc-thu/auth-service build
FROM base AS runtime
ENV NODE_ENV=production
COPY --from=build /repo /repo
WORKDIR /repo/apps/auth-service
EXPOSE 3010
CMD ["node", "dist/main.js"]
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testMatch: ["<rootDir>/test/**/*.spec.ts"],
};
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS auth_user (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text UNIQUE NOT NULL,
password_hash text NOT NULL,
role text NOT NULL CHECK (role IN ('user', 'admin')),
created_at timestamptz NOT NULL DEFAULT now()
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+40 -1
View File
@@ -1,5 +1,44 @@
{ {
"name": "@duoc-thu/auth-service", "name": "@duoc-thu/auth-service",
"private": true, "private": true,
"version": "0.0.0" "version": "0.0.0",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"migrate": "node dist/migrate.js",
"seed": "node dist/seed.js",
"lint": "eslint \"src/**/*.ts\" --max-warnings=0",
"test": "jest"
},
"dependencies": {
"@duoc-thu/shared-types": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@duoc-thu/config": "workspace:*",
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
} }
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "./auth/auth.module";
@Module({
imports: [AuthModule],
})
export class AppModule {}
@@ -0,0 +1,29 @@
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import type { AuthUser, LoginResponse } from "@duoc-thu/shared-types";
import type { Request } from "express";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { JwtAuthGuard, JwtPayload } from "./jwt.guard";
@Controller("auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("register")
register(@Body() body: RegisterDto): Promise<AuthUser> {
return this.auth.register(body.username, body.password);
}
@Post("login")
login(@Body() body: LoginDto): Promise<LoginResponse> {
return this.auth.login(body.username, body.password);
}
@UseGuards(JwtAuthGuard)
@Get("me")
me(@Req() request: Request): AuthUser {
const payload = (request as Request & { user: JwtPayload }).user;
return { username: payload.username, role: payload.role };
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { Pool } from "pg";
import { createPool } from "../db";
import { loadSettings } from "../config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "./jwt.guard";
const settings = loadSettings();
@Module({
imports: [
JwtModule.register({
secret: settings.jwtSecret,
signOptions: { expiresIn: settings.jwtExpiresIn },
}),
],
controllers: [AuthController],
providers: [
{ provide: Pool, useFactory: () => createPool(settings.postgresDsn) },
AuthService,
JwtAuthGuard,
],
})
export class AuthModule {}
@@ -0,0 +1,70 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import type { AuthUser, LoginResponse, UserRole } from "@duoc-thu/shared-types";
import * as bcrypt from "bcrypt";
import { Pool } from "pg";
const BCRYPT_ROUNDS = 12;
interface UserRow {
id: string;
username: string;
password_hash: string;
role: UserRole;
}
@Injectable()
export class AuthService {
constructor(
private readonly pool: Pool,
private readonly jwt: JwtService
) {}
async register(username: string, password: string): Promise<AuthUser> {
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
try {
const result = await this.pool.query<{ username: string; role: UserRole }>(
`INSERT INTO auth_user (username, password_hash, role)
VALUES ($1, $2, 'user')
RETURNING username, role`,
[username, passwordHash]
);
return result.rows[0];
} catch (error) {
// Postgres unique_violation — race-safe (the DB, not a prior SELECT,
// is the source of truth for "does this username already exist").
if ((error as { code?: string }).code === "23505") {
throw new ConflictException("username already taken");
}
throw error;
}
}
async login(username: string, password: string): Promise<LoginResponse> {
const result = await this.pool.query<UserRow>(
`SELECT id, username, password_hash, role FROM auth_user WHERE username = $1`,
[username]
);
const row = result.rows[0];
// Hash a dummy value on a miss so a wrong-username response takes
// roughly the same time as a wrong-password one — bcrypt.compare on a
// real hash is the expensive step; skipping it entirely on a missing
// user makes "does this username exist" a timing oracle.
const passwordHash = row?.password_hash ?? (await bcrypt.hash("", BCRYPT_ROUNDS));
const valid = await bcrypt.compare(password, passwordHash);
if (!row || !valid) {
throw new UnauthorizedException("invalid username or password");
}
const user: AuthUser = { username: row.username, role: row.role };
const token = await this.jwt.signAsync({
sub: row.id,
username: row.username,
role: row.role,
});
return { user, token };
}
}
@@ -0,0 +1,11 @@
import { IsString, Length } from "class-validator";
export class LoginDto {
@IsString()
@Length(1, 64)
username!: string;
@IsString()
@Length(1, 200)
password!: string;
}
@@ -0,0 +1,14 @@
import { IsString, Length, Matches } from "class-validator";
export class RegisterDto {
@IsString()
@Length(3, 64)
@Matches(/^[a-zA-Z0-9_.-]+$/, {
message: "username may only contain letters, digits, _ . -",
})
username!: string;
@IsString()
@Length(1, 200)
password!: string;
}
+38
View File
@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import type { Request } from "express";
export interface JwtPayload {
sub: string;
username: string;
role: "user" | "admin";
}
function bearerToken(request: Request): string | null {
const header = request.headers.authorization;
if (!header?.startsWith("Bearer ")) return null;
return header.slice("Bearer ".length);
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = bearerToken(request);
if (!token) throw new UnauthorizedException("missing bearer token");
try {
const payload = await this.jwt.verifyAsync<JwtPayload>(token);
(request as Request & { user: JwtPayload }).user = payload;
return true;
} catch {
throw new UnauthorizedException("invalid or expired token");
}
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Minimal env-var settings — mirrors the style of `apps/ai-service/config.py`
* (plain, explicit fields, no framework-specific config module) rather than
* pulling in `@nestjs/config` for four variables. */
export interface Settings {
port: number;
postgresDsn: string;
jwtSecret: string;
jwtExpiresIn: string;
adminSeedPassword: string;
demoSeedPassword: string;
}
export function loadSettings(): Settings {
const jwtSecret = process.env.JWT_SECRET ?? "";
if (!jwtSecret) {
// Fail closed at startup, not at the first login attempt — the same
// posture as ai-service's manifest check in bootstrap.py: a service that
// would sign tokens with an empty/guessable secret must not start at all.
throw new Error(
"JWT_SECRET is required and must not be empty. Refusing to start with " +
"no secret rather than silently signing tokens no one can trust."
);
}
return {
port: Number(process.env.PORT ?? 3010),
postgresDsn:
process.env.POSTGRES_DSN ??
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu",
jwtSecret,
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? "12h",
// Default "1" only exists for local/Compose dev, which never sets these.
// A real deployment sets them via the Helm Secret — see
// secret.adminSeedPassword in infra/helm/medical-chatbot/values.yaml.
adminSeedPassword: process.env.ADMIN_SEED_PASSWORD ?? "1",
demoSeedPassword: process.env.DEMO_SEED_PASSWORD ?? "1",
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Pool } from "pg";
/** One pool per process, unlike ai-service's Postgres adapter (which opens a
* connection per call — see its own docstring on why that's a known, not-yet
* fixed gap there). Node's `pg.Pool` makes per-request pooling the default,
* cheap way to do this correctly from the start rather than inheriting that
* same gap in a second language. */
export function createPool(dsn: string): Pool {
return new Pool({ connectionString: dsn, connectionTimeoutMillis: 5000 });
}
+16
View File
@@ -0,0 +1,16 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
import { loadSettings } from "./config";
async function bootstrap() {
const settings = loadSettings();
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
);
await app.listen(settings.port, "0.0.0.0");
}
bootstrap();
+26
View File
@@ -0,0 +1,26 @@
/** Mirrors `apps/ai-service/migrate.py`: plain SQL files, applied in sorted
* order, no ORM/migration-framework dependency. */
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createPool } from "./db";
import { loadSettings } from "./config";
async function main() {
const settings = loadSettings();
const pool = createPool(settings.postgresDsn);
const migrationsDir = join(__dirname, "..", "migrations");
const files = readdirSync(migrationsDir)
.filter((name) => name.endsWith(".sql"))
.sort();
for (const file of files) {
const statement = readFileSync(join(migrationsDir, file), "utf-8");
await pool.query(statement);
console.log(`Applied ${file}`);
}
await pool.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
/** Seeds the two accounts requested for this first slice: `admin` (role
* admin, unlocks /admin) and `demo` (role user, the "logged-in doctor"
* persona). Idempotent (ON CONFLICT DO NOTHING) — safe to run on every
* deploy alongside migrate.ts.
*
* Passwords come from ADMIN_SEED_PASSWORD / DEMO_SEED_PASSWORD, defaulting
* to "1" only when unset (local/Compose dev). Because ON CONFLICT DO NOTHING
* means whichever password lands on the first run is permanent, any
* deployment where `/admin` is actually reachable must set both env vars to
* real values — see secret.adminSeedPassword in
* infra/helm/medical-chatbot/values.yaml, which the chart requires
* explicitly once authService.seed.enabled is true.
*/
import * as bcrypt from "bcrypt";
import { createPool } from "./db";
import { loadSettings } from "./config";
async function main() {
const settings = loadSettings();
const pool = createPool(settings.postgresDsn);
const SEED_USERS: Array<{ username: string; password: string; role: "user" | "admin" }> = [
{ username: "admin", password: settings.adminSeedPassword, role: "admin" },
{ username: "demo", password: settings.demoSeedPassword, role: "user" },
];
for (const seed of SEED_USERS) {
const passwordHash = await bcrypt.hash(seed.password, 12);
await pool.query(
`INSERT INTO auth_user (username, password_hash, role)
VALUES ($1, $2, $3)
ON CONFLICT (username) DO NOTHING`,
[seed.username, passwordHash, seed.role]
);
console.log(`Seeded ${seed.username} (${seed.role})`);
}
await pool.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,72 @@
import "reflect-metadata";
import { ConflictException, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { AuthService } from "../src/auth/auth.service";
/** A fake `pg.Pool` — the goal here is auth logic (hashing, verification,
* conflict handling, JWT claims), not exercising a real Postgres, which
* `test_live_datastores.py`'s equivalent role covers for ai-service. */
function fakePool(rows: unknown[] = [], rejectCode?: string) {
return {
query: jest.fn().mockImplementation(async () => {
if (rejectCode) {
const error = new Error("duplicate") as Error & { code: string };
error.code = rejectCode;
throw error;
}
return { rows };
}),
} as unknown as import("pg").Pool;
}
const jwt = new JwtService({ secret: "test-secret-not-for-real-use" });
describe("AuthService", () => {
it("registers a new user and returns username/role", async () => {
const pool = fakePool([{ username: "demo", role: "user" }]);
const service = new AuthService(pool, jwt);
const result = await service.register("demo", "1");
expect(result).toEqual({ username: "demo", role: "user" });
});
it("rejects registering a username that already exists", async () => {
const pool = fakePool([], "23505");
const service = new AuthService(pool, jwt);
await expect(service.register("admin", "1")).rejects.toBeInstanceOf(
ConflictException
);
});
it("logs in with the correct password and issues a JWT carrying the role", async () => {
const passwordHash = await bcrypt.hash("1", 12);
const pool = fakePool([
{ id: "u-1", username: "admin", password_hash: passwordHash, role: "admin" },
]);
const service = new AuthService(pool, jwt);
const result = await service.login("admin", "1");
expect(result.user).toEqual({ username: "admin", role: "admin" });
const decoded = jwt.verify(result.token) as { sub: string; role: string };
expect(decoded.sub).toBe("u-1");
expect(decoded.role).toBe("admin");
});
it("rejects a wrong password", async () => {
const passwordHash = await bcrypt.hash("1", 12);
const pool = fakePool([
{ id: "u-1", username: "admin", password_hash: passwordHash, role: "admin" },
]);
const service = new AuthService(pool, jwt);
await expect(service.login("admin", "wrong")).rejects.toBeInstanceOf(
UnauthorizedException
);
});
it("rejects a username that does not exist without leaking that distinction", async () => {
const pool = fakePool([]);
const service = new AuthService(pool, jwt);
await expect(service.login("nobody", "1")).rejects.toBeInstanceOf(
UnauthorizedException
);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../packages/config/tsconfig-base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src"]
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LogIn, LogOut, UserRound } from "lucide-react";
import type { AuthUser } from "@duoc-thu/shared-types";
import { logout, me } from "@duoc-thu/api-client";
/** Public — visible on every page, not just `/admin`. Chat itself never
* requires this: an anonymous visitor keeps working exactly as before
* regardless of what this renders. Optional login exists here so `demo`
* (the logged-in "bác sĩ" persona) can sign in from the normal chat UI. */
export function AccountMenu() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
me()
.then(setUser)
.finally(() => setLoaded(true));
}, []);
if (!loaded) return null;
if (!user) {
return (
<Link
href="/admin/login"
className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary hover:bg-surface-hover"
>
<LogIn className="h-3.5 w-3.5" />
<span>Đăng nhập</span>
</Link>
);
}
return (
<div className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary">
<UserRound className="h-3.5 w-3.5" />
<span>{user.username}</span>
<button
aria-label="Đăng xuất"
onClick={async () => {
await logout();
setUser(null);
router.refresh();
}}
className="ml-1 text-txt-muted hover:text-txt-primary"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { login, AuthError } from "@duoc-thu/api-client";
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setPending(true);
try {
const user = await login({ username, password });
if (user.role !== "admin") {
setError("Tài khoản này không có quyền quản trị.");
return;
}
router.push("/admin");
router.refresh();
} catch (err) {
setError(
err instanceof AuthError && err.status === 401
? "Sai tên đăng nhập hoặc mật khẩu."
: "Không thể đăng nhập lúc này. Vui lòng thử lại."
);
} finally {
setPending(false);
}
}
return (
<div className="flex flex-1 items-center justify-center p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border-subtle bg-surface p-6 shadow-sm"
>
<h1 className="text-lg font-bold text-txt-primary">Đăng nhập quản trị</h1>
<div className="space-y-1">
<label className="text-xs font-semibold text-txt-secondary" htmlFor="username">
Tên đăng nhập
</label>
<input
id="username"
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-txt-secondary" htmlFor="password">
Mật khẩu
</label>
<input
id="password"
type="password"
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-xs font-medium text-status-danger">{error}</p>}
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-accent-primary py-2 text-sm font-semibold text-txt-inverse disabled:opacity-60"
>
{pending ? "Đang đăng nhập..." : "Đăng nhập"}
</button>
</form>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { AuthUser } from "@duoc-thu/shared-types";
import { logout, me } from "@duoc-thu/api-client";
export default function AdminPage() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
// The middleware guard already redirected anyone without a valid
// admin session before this component ever rendered — this fetch is
// for display, not the access-control decision itself.
me().then(setUser);
}, []);
return (
<div className="flex flex-1 flex-col gap-4 p-6">
<h1 className="text-lg font-bold text-txt-primary">Khu vực quản trị</h1>
<p className="text-sm text-txt-secondary">
Đăng nhập với: <strong>{user?.username ?? "..."}</strong> (
{user?.role ?? "..."})
</p>
<p className="max-w-xl text-xs text-txt-muted">
Đây bằng chứng chế phân quyền hoạt đng đúng chưa tính
năng quản trị cụ thể nào đây, chưa yêu cầu nào đưc nêu ra.
</p>
<button
onClick={async () => {
await logout();
router.push("/admin/login");
router.refresh();
}}
className="w-fit rounded-lg border border-border-subtle px-4 py-2 text-sm font-semibold text-txt-primary hover:bg-surface-hover"
>
Đăng xuất
</button>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import type { LoginResponse } from "@duoc-thu/shared-types";
import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from "../session";
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
export async function POST(request: Request) {
let username: string;
let password: string;
try {
const body = await request.json();
username = typeof body?.username === "string" ? body.username : "";
password = typeof body?.password === "string" ? body.password : "";
} catch {
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
}
if (!username || !password) {
return NextResponse.json({ error: "missing_credentials" }, { status: 400 });
}
let upstream: Response;
try {
upstream = await fetch(`${GATEWAY_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
cache: "no-store",
});
} catch {
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
}
if (!upstream.ok) {
return NextResponse.json(
{ error: "invalid_credentials" },
{ status: upstream.status === 401 ? 401 : 502 }
);
}
const data = (await upstream.json()) as LoginResponse;
const response = NextResponse.json({ user: data.user });
// httpOnly: never readable by client-side JS (XSS can't exfiltrate it).
// `secure` only outside local dev — Compose/k3s both terminate TLS in
// front of `web`, so the cookie is only ever sent in the clear on
// localhost, matching how every other secret in this repo treats
// local vs. deployed differently.
response.cookies.set(SESSION_COOKIE, data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
});
return response;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "../session";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.delete(SESSION_COOKIE);
return response;
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import type { AuthUser } from "@duoc-thu/shared-types";
import { SESSION_COOKIE } from "../session";
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
export async function GET() {
const token = cookies().get(SESSION_COOKIE)?.value;
if (!token) {
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
}
let upstream: Response;
try {
upstream = await fetch(`${GATEWAY_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
} catch {
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
}
if (!upstream.ok) {
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
}
const user = (await upstream.json()) as AuthUser;
return NextResponse.json(user);
}
+5
View File
@@ -0,0 +1,5 @@
/** Shared between the login/logout/me route handlers and `middleware.ts` —
* kept dependency-free (no Node-only imports) so `middleware.ts` can import
* it too; Next's Edge runtime middleware can't use arbitrary Node APIs. */
export const SESSION_COOKIE = "dt_session";
export const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; // matches auth-service's default JWT_EXPIRES_IN
+3
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui"; import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
import { NavTabs } from "./_components/NavTabs"; import { NavTabs } from "./_components/NavTabs";
import { AccountMenu } from "./_components/AccountMenu";
import { Pill, ShieldCheck, Cpu } from "lucide-react"; import { Pill, ShieldCheck, Cpu } from "lucide-react";
import "./globals.css"; import "./globals.css";
@@ -45,6 +46,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */} {/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
<ThemeSelector /> <ThemeSelector />
<AccountMenu />
{/* System Status Pill */} {/* System Status Pill */}
<div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm"> <div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm">
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" /> <Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
+33 -2
View File
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";
import { SESSION_COOKIE } from "./app/api/auth/session";
/** /**
* Rate limiting for the public API surface. * Rate limiting for the public API surface.
@@ -96,7 +98,36 @@ function matchRules(pathname: string) {
return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules; return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules;
} }
export function middleware(request: NextRequest) { /** `/admin/**` (except the login page itself) requires a valid session
* cookie carrying `role: "admin"`. Verified with the same `JWT_SECRET`
* auth-service signs with — Edge middleware can't call out to auth-service
* per request without adding real latency to every admin page load, and
* `jose` (unlike `jsonwebtoken`) works in the Edge runtime this file runs
* under, so local verification is both correct and the only option here. */
async function guardAdmin(request: NextRequest): Promise<NextResponse | null> {
const { pathname } = request.nextUrl;
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return null;
const token = request.cookies.get(SESSION_COOKIE)?.value;
const secret = process.env.JWT_SECRET;
if (!token || !secret) {
return NextResponse.redirect(new URL("/admin/login", request.url));
}
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
if (payload.role !== "admin") {
return NextResponse.redirect(new URL("/admin/login", request.url));
}
return null;
} catch {
return NextResponse.redirect(new URL("/admin/login", request.url));
}
}
export async function middleware(request: NextRequest) {
const adminRedirect = await guardAdmin(request);
if (adminRedirect) return adminRedirect;
const rules = matchRules(request.nextUrl.pathname); const rules = matchRules(request.nextUrl.pathname);
if (!rules) return NextResponse.next(); if (!rules) return NextResponse.next();
@@ -146,5 +177,5 @@ export function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/api/:path*"], matcher: ["/api/:path*", "/admin/:path*"],
}; };
+1
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^13.0.0", "framer-motion": "^13.0.0",
"jose": "^5.9.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.0", "react": "^18.3.0",
@@ -6,10 +6,22 @@
> outright on branch `agent/retire-compose-cicd`, not disabled. D1/D2/D3/D4 are > outright on branch `agent/retire-compose-cicd`, not disabled. D1/D2/D3/D4 are
> closed by deletion rather than by fixing the probe. `docs/operations.md` > closed by deletion rather than by fixing the probe. `docs/operations.md`
> Deploy/Rollback sections rewritten to describe the actual k3s/ArgoCD path. > Deploy/Rollback sections rewritten to describe the actual k3s/ArgoCD path.
> Compose EC2 itself (`52.0.158.61`) is untouched pending an explicit stop/ > PR #23 (both commits above) merged to `master` as `e804817`.
> terminate decision (PR D) — do not stop or terminate it without that go-ahead. >
> PR B (Helm hygiene) and PR C (rollback runbook, now mostly covered in > **Update, same day, further into the session:** owner gave explicit go-ahead
> `docs/operations.md`) remain open. > and Compose EC2 `i-039fc8f6102467a54` was **stopped** (not terminated) via
> `aws ec2 stop-instances`. Confirmed transition `running` → `stopping`. Root
> EBS still carries `DeleteOnTermination=true`, so it is intact and
> restartable, but it is no longer a live rollback target — `docs/operations.md`
> Rollback section now documents this as the current state, including the
> manual `start-instances` + compatibility-check steps needed before ever
> trusting it as a DNS fallback again. Nothing else about the box (AMI, EBS,
> tags, security group) was touched.
>
> PR B (Helm hygiene: drop the redundant `AWS_REGION` env block, add a
> baseline render-diff to `helm-chart.yml`) and the remaining half of PR C
> (an actual one-command k3s rollback script/workflow, not just the manual
> runbook now in `docs/operations.md`) remain open.
# Plan — make the CI/CD path safe after the k3s cutover (2026-08-18) # Plan — make the CI/CD path safe after the k3s cutover (2026-08-18)
+24 -17
View File
@@ -8,9 +8,10 @@ Production (`realvuxbaro.me`) chạy trên k3s, quản lý bởi ArgoCD Applicat
**`medical-chatbot-data`** (PostgreSQL + Qdrant, tách release để prune/self-heal **`medical-chatbot-data`** (PostgreSQL + Qdrant, tách release để prune/self-heal
phía app không bao giờ đụng vào dữ liệu). Cả hai đặt `syncPolicy.automated` với phía app không bao giờ đụng vào dữ liệu). Cả hai đặt `syncPolicy.automated` với
`selfHeal` + `prune` — **mọi merge vào `master` áp thẳng vào production, không `selfHeal` + `prune` — **mọi merge vào `master` áp thẳng vào production, không
có cổng duyệt thủ công.** EC2 Docker Compose (`52.0.158.61`) không còn nhận có cổng duyệt thủ công.** EC2 Docker Compose (`52.0.158.61`) đã **stop** từ
deploy tự động; xem `coordination/CLAUDE_PLAN_CICD_SAFETY_2026-08-18.md` cho 2026-08-18, không còn nhận deploy tự động và không còn là đường lui sống; xem
lý do và tình trạng hiện tại của máy đó. `coordination/CLAUDE_PLAN_CICD_SAFETY_2026-08-18.md` cho lý do và mục Rollback
bên dưới cho cách khởi động lại nếu cần.
## Deploy ## Deploy
@@ -39,25 +40,31 @@ Sau deploy (cả hai loại):
## Rollback ## Rollback
Không có workflow rollback một-cú-bấm cho k3s hiện tại — đây là phần còn thiếu, **Image bị lỗi (phổ biến nhất):** `gh workflow run rollback-k3s.yml -f
xem `coordination/CLAUDE_PLAN_CICD_SAFETY_2026-08-18.md` mục PR C. target_sha=<sha tốt lần trước>`. Lấy SHA đó từ lần chạy `build-practice-images.yml`
thành công gần nhất trước đó (`gh run list --workflow=build-practice-images.yml`).
**Image bị lỗi (phổ biến nhất):** gọi trực tiếp ArgoCD API bằng logic của Workflow tự xác nhận image đã tồn tại trên GHCR, repoint `medical-chatbot-app`
`sync_practice_argocd.py` nhưng với `IMAGE_TAG=<sha tốt lần trước>` — lấy SHA (dùng chung `sync_practice_argocd.py` với đường deploy xuôi), chờ tới khi
đó từ lần chạy `build-practice-images.yml` thành công gần nhất trước đó Application thật sự `Synced`/`Healthy` trên đúng tag đó (không chỉ tin lệnh
(`gh run list --workflow=build-practice-images.yml`). Không có nút bấm sẵn cho sync đã gọi xong — có race với ArgoCD `selfHeal`, xem comment trong script),
việc này; phải chạy script hoặc gọi API thủ công. rồi mới xác nhận `realvuxbaro.me` sống. Chưa tự động hoá; kích hoạt thủ công
qua `workflow_dispatch`, không trigger theo push.
**Chart/config bị lỗi:** `git revert` commit gây lỗi trên `master` qua PR bình **Chart/config bị lỗi:** `git revert` commit gây lỗi trên `master` qua PR bình
thường; ArgoCD `selfHeal` tự áp bản revert. Muốn ngay lập tức thay vì chờ chu kỳ thường; ArgoCD `selfHeal` tự áp bản revert. Muốn ngay lập tức thay vì chờ chu kỳ
poll, sync thủ công qua ArgoCD UI/CLI. poll, sync thủ công qua ArgoCD UI/CLI.
**Sự cố nặng ở tầng cluster** (k3s tự nó hỏng, không phải lỗi ở app): trong lúc **Sự cố nặng ở tầng cluster** (k3s tự nó hỏng, không phải lỗi ở app): Compose
Compose EC2 (`52.0.158.61`) còn tồn tại và chưa bị tắt, đường lui cuối cùng là EC2 (`i-039fc8f6102467a54`, `52.0.158.61`) đã **stop** (không terminate) ngày
trỏ A record `realvuxbaro.me` về IP đó (TTL 60s) — **chỉ đúng khi Compose đang 2026-08-18 theo quyết định chỉ dùng 1 EC2**không còn là đường lui sống**.
chạy bản tương thích với corpus/schema hiện tại**, không phải đường lui mặc Máy vẫn tồn tại (root EBS `DeleteOnTermination=true`, nên terminate mới mất
định. Một khi Compose bị dừng/xoá theo quyết định giữ 1 EC2, đường lui này vĩnh viễn), đóng băng ở code `df57e6b` từ trước cutover. Muốn dùng li làm
không còn. đường lui khẩn cấp: `aws ec2 start-instances --instance-ids
i-039fc8f6102467a54`, chờ container tự khởi động (`docker-compose.prod.yml`
không có auto-start service, kiểm tra lại), xác nhận corpus/schema còn tương
thích với migration hiện tại của production trước khi trỏ A record — thời
gian đứng máy càng lâu, xác suất lệch corpus càng cao. Đây là việc thủ công,
không có script/workflow nào làm sẵn.
Không có cơ chế nào ở trên tự rollback Qdrant corpus hay database migration. Không có cơ chế nào ở trên tự rollback Qdrant corpus hay database migration.
Với corpus, dùng snapshot/migration riêng; không rollback dữ liệu phá huỷ khi Với corpus, dùng snapshot/migration riêng; không rollback dữ liệu phá huỷ khi
+48
View File
@@ -0,0 +1,48 @@
# ArgoCD Applications (tracked copies)
Personal ArgoCD instance at `argocd.realvuxbaro.me`, on the same k3s cluster
that serves `realvuxbaro.me` — not the team's shared ArgoCD
(`argocd.vinmec.tech`), which this project does not use or have access to.
An earlier version of this directory described a plan to use the team's
instance (`docs-legacy/adr/0002-argocd-gitops.md`); that was never started,
and the owner deployed this personal instance instead because the team's
on-prem infra was heavier than needed for a single-operator project.
## Why these files exist
Until 2026-08-18, `medical-chatbot-app` and `medical-chatbot-data` existed
only as live objects inside ArgoCD — created once via the API during the
2026-08-17 cutover, never captured as a manifest. Losing the ArgoCD instance
would have meant losing the Application *definitions* too, even though the
Helm values they render (`values-production.yaml`,
`values-production-data.yaml`) were already tracked. These two files close
that gap: they are the disaster-recovery source for the Application objects
themselves.
## What's tracked vs. what isn't
`project`, `source` (repo/path/revision/valueFiles), `destination`, and
`syncPolicy` are tracked — verified byte-for-byte against the live objects on
2026-08-18. `medical-chatbot-app.yaml` deliberately omits the live
Application's inline `spec.source.helm.values`: it currently carries the
CI-rewritten image tags and, as a known gap, a plaintext Grafana admin
password that belongs in a Kubernetes Secret instead (needs cluster write
access this repo's automation doesn't have — not yet done). See the comment
at the top of that file before ever applying it.
## Files
- `applications/medical-chatbot-app.yaml` — ai-service, web, observability.
Serves both `realvuxbaro.me` and `readytochat.realvuxbaro.me`.
- `applications/medical-chatbot-data.yaml` — PostgreSQL + Qdrant, split into
its own Application so an app-side sync or prune can never touch the
PersistentVolumeClaims.
## Known gaps
- `project: default` on both — no RBAC/project scoping.
- Image tags move through `.github/scripts/sync_practice_argocd.py` and
`rollback-k3s.yml` mutating the live Application directly via the ArgoCD
API, not through a Git commit — a real GitOps setup would use an image
updater that writes the tag back to Git. Not yet built.
- The Grafana admin password gap above.
@@ -0,0 +1,51 @@
# Tracked copy of the live `medical-chatbot-app` Application (k3s, personal
# ArgoCD instance at argocd.realvuxbaro.me). Verified against the live object
# 2026-08-18 — every field below matches `status.sync.status: Synced`,
# `status.health.status: Healthy`.
#
# This Application serves BOTH realvuxbaro.me and readytochat.realvuxbaro.me
# (same release, same Pods) since the 2026-08-17 cutover.
#
# Deliberately NOT reproduced here: `spec.source.helm.values`. The live
# Application carries two things inline that must never live in Git:
#
# - aiService.image.tag / web.image.tag — rewritten on every push by
# .github/scripts/sync_practice_argocd.py (or by rollback-k3s.yml for a
# rollback). A tag committed here would go stale the moment CI runs again,
# and applying this file naively would silently roll the running image
# back to whatever tag happened to be in Git.
# - secret.grafanaAdminPassword — a real credential. It must never enter
# Git history. It belongs in a proper Kubernetes Secret referenced via
# `secret.existingSecret` (see infra/helm/medical-chatbot/values.yaml),
# not inline on the Application — that migration hasn't been done yet
# (it needs cluster write access this repo's automation doesn't have).
#
# Applying this file (`argocd app create -f` or the ArgoCD UI) recreates the
# Application's STRUCTURE — source, destination, sync policy — for disaster
# recovery. It does NOT restore the current image tag or the Grafana
# password: set the tag afterward via `rollback-k3s.yml` (target_sha = the
# last known-good commit) and re-enter the Grafana password by hand.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: medical-chatbot-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/BaoVu2k4/vsf-duocthu.git
targetRevision: master
path: infra/helm/medical-chatbot
helm:
valueFiles:
- values.yaml
- values-production.yaml
destination:
server: https://kubernetes.default.svc
namespace: medical-chatbot-app
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -0,0 +1,39 @@
# Tracked copy of the live `medical-chatbot-data` Application (k3s, personal
# ArgoCD instance at argocd.realvuxbaro.me). Verified against the live object
# 2026-08-18 — every field below matches `status.sync.status: Synced`,
# `status.health.status: Healthy`.
#
# Owns PostgreSQL and Qdrant only (the 15,100-point corpus and query
# history). Deliberately a separate Application from medical-chatbot-app so
# that an app-side sync failure, prune, or rollback can never delete the
# PersistentVolumeClaims — see values-production-data.yaml.
#
# Unlike medical-chatbot-app, the live Application carries no inline
# `spec.source.helm.values` at all: this release has no image tag CI rewrites
# and no secret, so it is already fully represented by this file plus
# values.yaml + values-production-data.yaml. Applying this file for disaster
# recovery needs no follow-up step.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: medical-chatbot-data
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/BaoVu2k4/vsf-duocthu.git
targetRevision: master
path: infra/helm/medical-chatbot
helm:
valueFiles:
- values.yaml
- values-production-data.yaml
destination:
server: https://kubernetes.default.svc
namespace: medical-chatbot-data
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+20 -21
View File
@@ -94,32 +94,31 @@ services:
# ports: ["8000:8000"] # ports: ["8000:8000"]
# depends_on: [qdrant] # depends_on: [qdrant]
# #
# api-gateway:
# build: ../../apps/api-gateway
# env_file: ../../apps/api-gateway/.env
# ports: ["3000:3000"]
# depends_on: [auth-service, user-service, chat-service]
#
# auth-service:
# build: ../../apps/auth-service
# env_file: ../../apps/auth-service/.env
# depends_on: [postgres]
#
# user-service:
# build: ../../apps/user-service
# env_file: ../../apps/user-service/.env
# depends_on: [postgres]
#
# chat-service:
# build: ../../apps/chat-service
# env_file: ../../apps/chat-service/.env
# depends_on: [postgres, ai-service]
#
# web: # web:
# build: ../../apps/web # build: ../../apps/web
# ports: ["3001:3000"] # ports: ["3001:3000"]
# depends_on: [api-gateway] # depends_on: [api-gateway]
# auth-service and api-gateway are real (see the plan this was built
# from) — user-service/chat-service stay commented above pending their own
# scope, and ai-service/web stay commented per the pre-existing
# host-during-dev convention noted at the top of this file.
auth-service:
build: ../../apps/auth-service
env_file: ../../apps/auth-service/.env
ports:
- "3010:3010"
depends_on:
- postgres
api-gateway:
build: ../../apps/api-gateway
env_file: ../../apps/api-gateway/.env
ports:
- "3000:3000"
depends_on:
- auth-service
volumes: volumes:
postgres-data: postgres-data:
qdrant-data: qdrant-data:
@@ -0,0 +1,56 @@
{{- if .Values.apiGateway.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "medical-chatbot.fullname" . }}-api-gateway
labels:
{{- include "medical-chatbot.labels" . | nindent 4 }}
app.kubernetes.io/component: api-gateway
spec:
replicas: {{ .Values.apiGateway.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api-gateway
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api-gateway
annotations:
checksum/runtime-config: {{ dict "port" .Values.apiGateway.service.port | toJson | sha256sum | quote }}
spec:
serviceAccountName: {{ include "medical-chatbot.serviceAccountName" . }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
containers:
- name: api-gateway
image: {{ include "medical-chatbot.image" (dict "image" .Values.apiGateway.image "name" "apiGateway") | quote }}
imagePullPolicy: {{ .Values.apiGateway.image.pullPolicy }}
ports:
- { name: http, containerPort: {{ .Values.apiGateway.service.port }} }
env:
- name: PORT
value: {{ .Values.apiGateway.service.port | quote }}
- name: AUTH_SERVICE_URL
value: {{ printf "http://%s-auth-service:%d" (include "medical-chatbot.fullname" .) (.Values.authService.service.port | int) | quote }}
resources:
{{- toYaml .Values.apiGateway.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "medical-chatbot.fullname" . }}-api-gateway
labels:
{{- include "medical-chatbot.labels" . | nindent 4 }}
app.kubernetes.io/component: api-gateway
spec:
type: {{ .Values.apiGateway.service.type }}
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api-gateway
ports:
- name: http
port: {{ .Values.apiGateway.service.port }}
targetPort: http
{{- end }}
@@ -0,0 +1,122 @@
{{- if .Values.authService.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "medical-chatbot.fullname" . }}-auth-service
labels:
{{- include "medical-chatbot.labels" . | nindent 4 }}
data:
JWT_EXPIRES_IN: {{ .Values.authService.config.jwtExpiresIn | quote }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "medical-chatbot.fullname" . }}-auth-service
labels:
{{- include "medical-chatbot.labels" . | nindent 4 }}
app.kubernetes.io/component: auth-service
spec:
replicas: {{ .Values.authService.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: auth-service
template:
metadata:
labels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: auth-service
annotations:
checksum/runtime-config: {{ .Values.authService.config | toJson | sha256sum | quote }}
spec:
serviceAccountName: {{ include "medical-chatbot.serviceAccountName" . }}
imagePullSecrets:
{{- toYaml .Values.global.imagePullSecrets | nindent 8 }}
{{- if or .Values.authService.migration.enabled .Values.authService.seed.enabled }}
initContainers:
{{- if .Values.authService.migration.enabled }}
- name: migrate
image: {{ include "medical-chatbot.image" (dict "image" .Values.authService.image "name" "authService") | quote }}
imagePullPolicy: {{ .Values.authService.image.pullPolicy }}
command: ["node", "dist/migrate.js"]
env:
- name: POSTGRES_DSN
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: postgres-dsn
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: jwt-secret
{{- end }}
{{- if .Values.authService.seed.enabled }}
- name: seed
image: {{ include "medical-chatbot.image" (dict "image" .Values.authService.image "name" "authService") | quote }}
imagePullPolicy: {{ .Values.authService.image.pullPolicy }}
command: ["node", "dist/seed.js"]
env:
- name: POSTGRES_DSN
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: postgres-dsn
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: jwt-secret
- name: ADMIN_SEED_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: admin-seed-password
- name: DEMO_SEED_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: demo-seed-password
{{- end }}
{{- end }}
containers:
- name: auth-service
image: {{ include "medical-chatbot.image" (dict "image" .Values.authService.image "name" "authService") | quote }}
imagePullPolicy: {{ .Values.authService.image.pullPolicy }}
ports:
- { name: http, containerPort: {{ .Values.authService.service.port }} }
envFrom:
- configMapRef: { name: {{ include "medical-chatbot.fullname" . }}-auth-service }
env:
- name: PORT
value: {{ .Values.authService.service.port | quote }}
- name: POSTGRES_DSN
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: postgres-dsn
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: jwt-secret
resources:
{{- toYaml .Values.authService.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "medical-chatbot.fullname" . }}-auth-service
labels:
{{- include "medical-chatbot.labels" . | nindent 4 }}
app.kubernetes.io/component: auth-service
spec:
type: {{ .Values.authService.service.type }}
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: auth-service
ports:
- name: http
port: {{ .Values.authService.service.port }}
targetPort: http
{{- end }}
@@ -10,4 +10,20 @@ stringData:
postgres-password: {{ .Values.secret.postgresPassword | quote }} postgres-password: {{ .Values.secret.postgresPassword | quote }}
postgres-dsn: {{ printf "postgresql://duoc_thu:%s@%s:5432/duoc_thu" .Values.secret.postgresPassword (default (printf "%s-postgres" (include "medical-chatbot.fullname" .)) .Values.secret.postgresHost) | quote }} postgres-dsn: {{ printf "postgresql://duoc_thu:%s@%s:5432/duoc_thu" .Values.secret.postgresPassword (default (printf "%s-postgres" (include "medical-chatbot.fullname" .)) .Values.secret.postgresHost) | quote }}
grafana-admin-password: {{ .Values.secret.grafanaAdminPassword | quote }} grafana-admin-password: {{ .Values.secret.grafanaAdminPassword | quote }}
{{- if and .Values.aws.staticCredentials.enabled .Values.aws.staticCredentials.accessKeyId }}
aws-access-key-id: {{ .Values.aws.staticCredentials.accessKeyId | quote }}
aws-secret-access-key: {{ .Values.aws.staticCredentials.secretAccessKey | quote }}
{{- end }}
{{- if or .Values.authService.enabled .Values.apiGateway.enabled }}
{{/* Required (not defaulted) once either service is turned on — same
fail-closed posture as the image-tag guard above: a guessable or empty
secret here would let anyone forge an admin JWT. */}}
jwt-secret: {{ required "secret.jwtSecret is required when authService or apiGateway is enabled" .Values.secret.jwtSecret | quote }}
{{- end }}
{{- if .Values.authService.seed.enabled }}
{{/* Required (not defaulted) once the seed job runs — see the comment on
secret.adminSeedPassword in values.yaml for why "1" must never reach here. */}}
admin-seed-password: {{ required "secret.adminSeedPassword is required when authService.seed.enabled" .Values.secret.adminSeedPassword | quote }}
demo-seed-password: {{ required "secret.demoSeedPassword is required when authService.seed.enabled" .Values.secret.demoSeedPassword | quote }}
{{- end }}
{{- end }} {{- end }}
@@ -27,6 +27,20 @@ spec:
env: env:
- name: AI_SERVICE_URL - name: AI_SERVICE_URL
value: {{ printf "http://%s-ai-service:%v" (include "medical-chatbot.fullname" .) .Values.aiService.service.port | quote }} value: {{ printf "http://%s-ai-service:%v" (include "medical-chatbot.fullname" .) .Values.aiService.service.port | quote }}
{{- if .Values.apiGateway.enabled }}
- name: API_GATEWAY_URL
value: {{ printf "http://%s-api-gateway:%d" (include "medical-chatbot.fullname" .) (.Values.apiGateway.service.port | int) | quote }}
{{- end }}
{{- if or .Values.authService.enabled .Values.apiGateway.enabled }}
{{/* Only middleware.ts needs this (verifies the admin JWT locally
at the edge) — the BFF routes never see it, they just forward the
cookie's raw token to the gateway. */}}
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "medical-chatbot.secretName" . }}
key: jwt-secret
{{- end }}
ports: ports:
- { name: http, containerPort: 3000 } - { name: http, containerPort: 3000 }
readinessProbe: readinessProbe:
@@ -53,6 +53,19 @@ aiService:
answerModelId: qwen.qwen3-next-80b-a3b answerModelId: qwen.qwen3-next-80b-a3b
rerankEnabled: true rerankEnabled: true
# First real-auth rollout to production (2026-08-18). The seed `admin`/`demo`
# passwords are NOT set here — they're secret, so they go inline on the live
# Application the same way secret.grafanaAdminPassword already does (see the
# comment in infra/argocd/applications/medical-chatbot-app.yaml). The chart
# fails closed via `required` if secret.jwtSecret / adminSeedPassword /
# demoSeedPassword are missing, so an inline-values update that forgets one
# of them breaks sync loudly instead of seeding "1".
authService:
enabled: true
apiGateway:
enabled: true
observability: observability:
grafana: grafana:
# Dashboards stay open so a demo needs no credentials, but read-only: this # Dashboards stay open so a demo needs no credentials, but read-only: this
+82
View File
@@ -18,6 +18,46 @@ secret:
# data/app split pattern) — overrides the default in-release host. # data/app split pattern) — overrides the default in-release host.
postgresHost: "" postgresHost: ""
grafanaAdminPassword: change-me grafanaAdminPassword: change-me
# Required (chart render fails without it) once authService or apiGateway
# is enabled — signs/verifies every JWT. Must be the same value both
# services see, which sharing one Secret key already guarantees.
jwtSecret: ""
# Required (chart render fails without it) once authService.seed.enabled is
# true — the seed job's ON CONFLICT DO NOTHING means whatever password goes
# in on the first run is what `admin`/`demo` keep, permanently. Forcing this
# to be set explicitly (no "1" default) stops a real deployment from ever
# seeding the guessable dev password. Local/Compose dev is unaffected: that
# path calls seed.js directly with no env vars set, which still falls back
# to "1" in apps/auth-service/src/config.ts.
adminSeedPassword: ""
demoSeedPassword: ""
# AWS credentials for Bedrock (query embedding, rerank, generation).
#
# Leave `staticCredentials.enabled: false` on AWS-hosted nodes. Both the
# Compose host and the k3s node run on EC2, where boto3 resolves credentials
# from the instance role over IMDS and no key material exists on disk or in
# any manifest — that is the posture every deployment has used so far, and it
# is the safer one. `adapters/bedrock_converse.py` builds its client without
# passing credentials, so boto3's own chain applies: environment variables
# first, then shared config, then the instance role.
#
# Turn this on only for a cluster with no instance role and no IRSA — an
# on-prem cluster, for example — where that chain would find nothing and every
# Bedrock call would fail to authenticate. Because environment variables win
# over the instance role, leaving this off keeps current behaviour exactly.
aws:
# Overrides config.py's `aws_region` default (us-east-1); empty leaves the
# application default in place. The adapter passes this region to boto3
# explicitly, and pydantic-settings reads this same variable name.
region: ""
staticCredentials:
enabled: false
# Used when `secret.create` is true. When pointing at an existing Secret
# (`secret.existingSecret`), leave these empty and add the same two keys —
# `aws-access-key-id` and `aws-secret-access-key` — to that Secret instead.
accessKeyId: ""
secretAccessKey: ""
aiService: aiService:
enabled: true enabled: true
@@ -68,6 +108,48 @@ web:
requests: { cpu: 50m, memory: 128Mi } requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi } limits: { cpu: 500m, memory: 512Mi }
# Both default OFF: this first slice's code exists and can be deployed, but
# turning it on for real production traffic is a separate, later, explicit
# decision — see the plan this was built from. A fresh `helm install` with
# every other default is unaffected either way.
authService:
enabled: false
replicaCount: 1
image:
repository: duocthu-auth-service
tag: local
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 3010
config:
jwtExpiresIn: 12h
migration:
enabled: true
# Idempotent (ON CONFLICT DO NOTHING) — safe to leave on every deploy.
# Disable once real registration replaces the two seed accounts, or if the
# `admin`/`demo` passwords have been rotated and must not be reset back to
# `"1"` by a future rollout.
seed:
enabled: true
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 250m, memory: 256Mi }
apiGateway:
enabled: false
replicaCount: 1
image:
repository: duocthu-api-gateway
tag: local
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 3000
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 250m, memory: 256Mi }
ingress: ingress:
enabled: false enabled: false
className: nginx className: nginx
+36
View File
@@ -0,0 +1,36 @@
import type { AuthUser, LoginRequest } from "@duoc-thu/shared-types";
export class AuthError extends Error {
constructor(readonly status: number) {
super(`Auth request failed (${status})`);
this.name = "AuthError";
}
}
/** Calls the app's own `/api/auth/login` route (which holds the gateway URL
* and sets the httpOnly session cookie) — mirrors `sendChatMessage`'s
* BFF-first pattern rather than calling api-gateway from the browser. */
export async function login(credentials: LoginRequest): Promise<AuthUser> {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new AuthError(response.status);
const data = (await response.json()) as { user: AuthUser };
return data.user;
}
export async function logout(): Promise<void> {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new AuthError(response.status);
}
/** Returns `null` on 401 (no/expired session) rather than throwing — "not
* logged in" is an expected, common state for this page, not an error. */
export async function me(): Promise<AuthUser | null> {
const response = await fetch("/api/auth/me");
if (response.status === 401) return null;
if (!response.ok) throw new AuthError(response.status);
return (await response.json()) as AuthUser;
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./sendChatMessage"; export * from "./sendChatMessage";
export * from "./getDrugSuggestions"; export * from "./getDrugSuggestions";
export * from "./auth";
+6
View File
@@ -17,9 +17,15 @@ export function buildMockResponse(userContent: string): SendMessageResponse {
`dùng để tra cứu. Câu hỏi nhận được: "${userContent}".`, `dùng để tra cứu. Câu hỏi nhận được: "${userContent}".`,
citations: [ citations: [
{ {
chunkId: "mock-paracetamol-lieu_dung-1",
drugName: "PARACETAMOL", drugName: "PARACETAMOL",
sectionType: "lieu_dung", sectionType: "lieu_dung",
sourcePageRange: [412, 413], sourcePageRange: [412, 413],
physicalPage: 411,
// Deliberately empty, same reasoning as `content` above — a
// plausible-looking mock snippet is a plausible-looking mock dose.
snippet: "",
isQuarantined: false,
}, },
], ],
disclaimer: disclaimer:
+34
View File
@@ -0,0 +1,34 @@
/** Shared ESLint base for the NestJS services (`auth-service`, `api-gateway`
* today). Legacy `.eslintrc` format, not flat config — matches
* `apps/web/.eslintrc.json`'s ESLint 8 era rather than introducing a second
* config format into the repo.
*
* Consuming package.json needs its own `eslint`, `@typescript-eslint/parser`,
* `@typescript-eslint/eslint-plugin` devDependencies — pnpm's non-hoisted
* node_modules means a shared config can't lend its own plugin resolution to
* a package that doesn't declare the plugin itself.
*
* Referenced from each service's `.eslintrc.json` as a relative file path
* (`"extends": "../../packages/config/eslint-preset/index.js"`), not as
* `"@duoc-thu/config/eslint-preset"` — tried that first, and ESLint 8's
* legacy shareable-config resolution only auto-resolves package names
* shaped like `eslint-config-*` / `@scope/eslint-config[-*]`, not an
* arbitrary subpath of an arbitrarily-named package. Confirmed live: the
* package-name form failed with "couldn't find the config", the relative
* path did not. */
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: { node: true, jest: true },
parserOptions: { sourceType: "module", ecmaVersion: 2022 },
rules: {
// NestJS constructor-injection params are conventionally unused by name
// (e.g. `constructor(private readonly foo: Foo)`); flag genuinely unused
// locals/imports without flagging that idiom.
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
},
ignorePatterns: ["dist", "node_modules"],
};
+6 -1
View File
@@ -1,5 +1,10 @@
{ {
"name": "@duoc-thu/config", "name": "@duoc-thu/config",
"private": true, "private": true,
"version": "0.0.0" "version": "0.0.0",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0"
}
} }
+25
View File
@@ -0,0 +1,25 @@
export type UserRole = "user" | "admin";
export interface AuthUser {
username: string;
role: UserRole;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
user: AuthUser;
// The signed JWT. Present in the auth-service/api-gateway wire response
// only — the web BFF route that calls this consumes `token` to set an
// httpOnly cookie and does NOT forward it into its own browser-facing
// response body. Never read this field in client-side/browser code.
token: string;
}
export interface RegisterRequest {
username: string;
password: string;
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./dto/auth";
export * from "./dto/chat"; export * from "./dto/chat";
export * from "./dto/session"; export * from "./dto/session";
+4796 -144
View File
File diff suppressed because it is too large Load Diff