Enable auth-service/api-gateway on production, build their images in CI
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
"""Point the k3s practice cluster's ArgoCD Application at a freshly-built
|
||||
image tag, then trigger an immediate sync.
|
||||
"""Point the k3s ArgoCD Application at an image tag, then trigger an
|
||||
immediate sync.
|
||||
|
||||
Only touches `medical-chatbot-app` on the practice cluster
|
||||
(argocd.realvuxbaro.me). Never touches production — the EC2 Compose
|
||||
deployment isn't ArgoCD-managed at all.
|
||||
`medical-chatbot-app` (argocd.realvuxbaro.me) is the production Application —
|
||||
it serves both realvuxbaro.me and readytochat.realvuxbaro.me — since the
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
- apps/ai-service/**
|
||||
- apps/web/**
|
||||
- apps/auth-service/**
|
||||
- apps/api-gateway/**
|
||||
- packages/**
|
||||
- ingestion/data/verified/drug_entities.json
|
||||
- .github/workflows/build-practice-images.yml
|
||||
@@ -66,6 +68,33 @@ jobs:
|
||||
cache-from: type=gha,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
|
||||
env:
|
||||
ARGOCD_PRACTICE_URL: ${{ secrets.ARGOCD_PRACTICE_URL }}
|
||||
|
||||
@@ -102,3 +102,28 @@ jobs:
|
||||
|
||||
- name: 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
|
||||
|
||||
@@ -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
|
||||
@@ -12,25 +12,30 @@ The former numbered `00–29` material and historical plans are retained in
|
||||
there until reviewed. See the canonical
|
||||
[documentation policy](docs/documentation-policy.md) for source precedence.
|
||||
|
||||
> **Status** (2026-08-11): **live in production at
|
||||
> [realvuxbaro.me](https://realvuxbaro.me)** — a real RAG chatbot over the
|
||||
> whole formulary, not a scaffold. What exists and what does not:
|
||||
> **Status** (2026-08-18): **live in production at
|
||||
> [realvuxbaro.me](https://realvuxbaro.me)**, running on **k3s + ArgoCD**
|
||||
> 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 |
|
||||
> |---|---|
|
||||
> | `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/web/` | Done — chat UI with citation/evidence panel |
|
||||
> | `apps/api-gateway`, `auth-service`, `user-service`, `chat-service` | **Not built** — `README.md` + `package.json` only |
|
||||
> | `apps/web/` | Done — chat UI with citation/evidence panel, optional login |
|
||||
> | `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 |
|
||||
> | `infra/docker/` | Done — production runs Compose, including the Prometheus/Grafana/Tempo observability overlay |
|
||||
> | `infra/helm/medical-chatbot/` | Built and validated as an offline migration kit; **not deployed** to Docker Desktop, k3s or ArgoCD |
|
||||
> | `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 |
|
||||
> | `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/` | **This is production.** Two ArgoCD Applications, `medical-chatbot-app` and `medical-chatbot-data`, both tracked in `infra/argocd/applications/` |
|
||||
> | 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
|
||||
> **directly** to `apps/ai-service`; there is no authentication layer. See
|
||||
> `apps/web`'s chat/history/feedback/sections routes still call
|
||||
> `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
|
||||
> 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
|
||||
|
||||
@@ -56,8 +61,9 @@ docs-legacy/ raw notes, historical plans and ADRs pending review
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js + pnpm (JS workspace: `apps/web`, `packages/*`; the NestJS service
|
||||
directories are unbuilt placeholders)
|
||||
- Node.js + pnpm (JS workspace: `apps/web`, `apps/auth-service`,
|
||||
`apps/api-gateway`, `packages/*`; `apps/user-service`/`apps/chat-service`
|
||||
are still unbuilt placeholders)
|
||||
- Python 3.11+ (`apps/ai-service`, `ingestion`)
|
||||
- Docker (local Postgres + Qdrant via `infra/docker/docker-compose.yml`)
|
||||
- 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 |
|
||||
| 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
|
||||
`infra/docker/docker-compose.observability.yml`. It leaves
|
||||
`docker-compose.prod.yml` unchanged. A deployment, when explicitly approved,
|
||||
uses both files:
|
||||
|
||||
```powershell
|
||||
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**.
|
||||
Grafana is served through the same k3s ingress as the app, at
|
||||
`https://realvuxbaro.me/grafana/`. Anonymous access is on but demoted to
|
||||
**Viewer** (dashboards load with no login; write actions need the admin
|
||||
account). Prometheus has no public URL — use **Grafana -> Explore ->
|
||||
Prometheus** for PromQL, or **Explore -> Tempo** with a returned
|
||||
`X-Trace-ID` to investigate a slow request.
|
||||
|
||||
## Production
|
||||
|
||||
Live at [realvuxbaro.me](https://realvuxbaro.me): a single EC2 `t3.large`
|
||||
running `infra/docker/docker-compose.prod.yml` (postgres, qdrant, ai-service,
|
||||
web, Caddy for automatic Let's Encrypt TLS). Bedrock is reached through an IAM
|
||||
instance role — there are no long-lived AWS keys on the box or in any env file.
|
||||
Live at [realvuxbaro.me](https://realvuxbaro.me), running on **k3s +
|
||||
ArgoCD** (a personal, owner-operated cluster — not the team's shared
|
||||
infrastructure) since the 2026-08-17 cutover. Two ArgoCD Applications:
|
||||
`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
|
||||
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.
|
||||
Two things deploy independently:
|
||||
|
||||
This is **interim infrastructure**, not the end state. The intended target is
|
||||
still the team's self-hosted **Gitea** (company domain) plus their **ArgoCD**
|
||||
instance, per `docs-legacy/adr/0002-argocd-gitops.md` — that work is *not started*,
|
||||
not cancelled. 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.
|
||||
- **App code** (`apps/ai-service/**`, `apps/web/**`, `packages/**`) — a push
|
||||
to `master` triggers `.github/workflows/build-practice-images.yml`, which
|
||||
builds and pushes GHCR images tagged by commit SHA, then repoints
|
||||
`medical-chatbot-app` at the new tag. `ci.yml` runs in parallel and does
|
||||
**not** gate this — a red test suite does not block a deploy.
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../packages/config/eslint-preset/index.js"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
rootDir: ".",
|
||||
testMatch: ["<rootDir>/test/**/*.spec.ts"],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
{
|
||||
"name": "@duoc-thu/api-gateway",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyModule } from "./proxy/proxy.module";
|
||||
|
||||
@Module({
|
||||
imports: [ProxyModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../packages/config/eslint-preset/index.js"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,44 @@
|
||||
{
|
||||
"name": "@duoc-thu/auth-service",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 là bằng chứng cơ chế phân quyền hoạt động đúng — chưa có tính
|
||||
năng quản trị cụ thể nào ở đây, vì chưa có 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
|
||||
import { NavTabs } from "./_components/NavTabs";
|
||||
import { AccountMenu } from "./_components/AccountMenu";
|
||||
import { Pill, ShieldCheck, Cpu } from "lucide-react";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -45,6 +46,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
|
||||
<ThemeSelector />
|
||||
|
||||
<AccountMenu />
|
||||
|
||||
{/* 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">
|
||||
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
|
||||
|
||||
+33
-2
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } 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.
|
||||
@@ -96,7 +98,36 @@ function matchRules(pathname: string) {
|
||||
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);
|
||||
if (!rules) return NextResponse.next();
|
||||
|
||||
@@ -146,5 +177,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/:path*"],
|
||||
matcher: ["/api/:path*", "/admin/:path*"],
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^13.0.0",
|
||||
"jose": "^5.9.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
|
||||
@@ -6,10 +6,22 @@
|
||||
> 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`
|
||||
> Deploy/Rollback sections rewritten to describe the actual k3s/ArgoCD path.
|
||||
> Compose EC2 itself (`52.0.158.61`) is untouched pending an explicit stop/
|
||||
> 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
|
||||
> `docs/operations.md`) remain open.
|
||||
> PR #23 (both commits above) merged to `master` as `e804817`.
|
||||
>
|
||||
> **Update, same day, further into the session:** owner gave explicit go-ahead
|
||||
> 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)
|
||||
|
||||
|
||||
+24
-17
@@ -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
|
||||
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
|
||||
có cổng duyệt thủ công.** EC2 Docker Compose (`52.0.158.61`) không còn nhận
|
||||
deploy tự động; xem `coordination/CLAUDE_PLAN_CICD_SAFETY_2026-08-18.md` cho
|
||||
lý do và tình trạng hiện tại của máy đó.
|
||||
có cổng duyệt thủ công.** EC2 Docker Compose (`52.0.158.61`) đã **stop** từ
|
||||
2026-08-18, không còn nhận deploy tự động và không còn là đường lui sống; xem
|
||||
`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
|
||||
|
||||
@@ -39,25 +40,31 @@ Sau deploy (cả hai loại):
|
||||
|
||||
## 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,
|
||||
xem `coordination/CLAUDE_PLAN_CICD_SAFETY_2026-08-18.md` mục PR C.
|
||||
|
||||
**Image bị lỗi (phổ biến nhất):** gọi trực tiếp ArgoCD API bằng logic của
|
||||
`sync_practice_argocd.py` nhưng với `IMAGE_TAG=<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`). Không có nút bấm sẵn cho
|
||||
việc này; phải chạy script hoặc gọi API thủ công.
|
||||
**Image bị lỗi (phổ biến nhất):** `gh workflow run rollback-k3s.yml -f
|
||||
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`).
|
||||
Workflow tự xác nhận image đã tồn tại trên GHCR, repoint `medical-chatbot-app`
|
||||
(dùng chung `sync_practice_argocd.py` với đường deploy xuôi), chờ tới khi
|
||||
Application thật sự `Synced`/`Healthy` trên đúng tag đó (không chỉ tin lệnh
|
||||
sync đã gọi xong — có race với ArgoCD `selfHeal`, xem comment trong script),
|
||||
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
|
||||
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.
|
||||
|
||||
**Sự cố nặng ở tầng cluster** (k3s tự nó hỏng, không phải lỗi ở app): trong lúc
|
||||
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à
|
||||
trỏ A record `realvuxbaro.me` về IP đó (TTL 60s) — **chỉ đúng khi Compose đang
|
||||
chạy bản tương thích với corpus/schema hiện tại**, không phải đường lui mặc
|
||||
định. Một khi Compose bị dừng/xoá theo quyết định giữ 1 EC2, đường lui này
|
||||
không còn.
|
||||
**Sự cố nặng ở tầng cluster** (k3s tự nó hỏng, không phải lỗi ở app): Compose
|
||||
EC2 (`i-039fc8f6102467a54`, `52.0.158.61`) đã **stop** (không terminate) ngày
|
||||
2026-08-18 theo quyết định chỉ dùng 1 EC2 — **không còn là đường lui sống**.
|
||||
Máy vẫn tồn tại (root EBS `DeleteOnTermination=true`, nên terminate mới mất
|
||||
vĩnh viễn), đóng băng ở code `df57e6b` từ trước cutover. Muốn dùng lại làm
|
||||
đườ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.
|
||||
Với corpus, dùng snapshot/migration riêng; không rollback dữ liệu phá huỷ khi
|
||||
|
||||
@@ -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
|
||||
@@ -94,32 +94,31 @@ services:
|
||||
# ports: ["8000:8000"]
|
||||
# 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:
|
||||
# build: ../../apps/web
|
||||
# ports: ["3001:3000"]
|
||||
# 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:
|
||||
postgres-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-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 }}
|
||||
{{- 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 }}
|
||||
|
||||
@@ -27,6 +27,20 @@ spec:
|
||||
env:
|
||||
- name: AI_SERVICE_URL
|
||||
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:
|
||||
- { name: http, containerPort: 3000 }
|
||||
readinessProbe:
|
||||
|
||||
@@ -53,6 +53,19 @@ aiService:
|
||||
answerModelId: qwen.qwen3-next-80b-a3b
|
||||
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:
|
||||
grafana:
|
||||
# Dashboards stay open so a demo needs no credentials, but read-only: this
|
||||
|
||||
@@ -18,6 +18,46 @@ secret:
|
||||
# data/app split pattern) — overrides the default in-release host.
|
||||
postgresHost: ""
|
||||
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:
|
||||
enabled: true
|
||||
@@ -68,6 +108,48 @@ web:
|
||||
requests: { cpu: 50m, memory: 128Mi }
|
||||
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:
|
||||
enabled: false
|
||||
className: nginx
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./sendChatMessage";
|
||||
export * from "./getDrugSuggestions";
|
||||
export * from "./auth";
|
||||
|
||||
|
||||
@@ -17,9 +17,15 @@ export function buildMockResponse(userContent: string): SendMessageResponse {
|
||||
`dùng để tra cứu. Câu hỏi nhận được: "${userContent}".`,
|
||||
citations: [
|
||||
{
|
||||
chunkId: "mock-paracetamol-lieu_dung-1",
|
||||
drugName: "PARACETAMOL",
|
||||
sectionType: "lieu_dung",
|
||||
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:
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"name": "@duoc-thu/config",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./dto/auth";
|
||||
export * from "./dto/chat";
|
||||
export * from "./dto/session";
|
||||
|
||||
|
||||
Generated
+4796
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user