# User-Service Authentication and Minikube Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Authenticate chat users through the deployed user-service, personalize the interface from its profile, and run the complete chat stack in minikube at `http://localhost:3000`. **Architecture:** A server-only client posts credentials to `${USER_SERVICE_URL}/api/user/auth`, validates the returned public profile, and upserts a passwordless local user projection while preserving the UUID used by chat history. The app continues to issue 24-hour JWTs. Kubernetes resources deploy the UI, its PostgreSQL and MinIO beside the existing user-service, while a port-forward exposes the UI on localhost. **Tech Stack:** Next.js 16, React 19, TypeScript, PostgreSQL 16, MinIO/S3, Vitest, Kubernetes, minikube. ## Global Constraints - Work in the existing `main` worktree and preserve unrelated dirty changes. - The browser calls only `/api/auth/login`; only the Next.js server calls user-service. - Use `USER_SERVICE_URL=http://user-service.aegida-services.svc.cluster.local:8080` in minikube. - Upstream authentication timeout is 5 seconds. - Invalid credentials map to 401; upstream/network/schema failures map to 503. - Never store, log, return, or place a password in JWT claims. - Keep the local UUID primary key and `last_model_id` stable across profile refreshes. - Make the deployed service available only through localhost port-forwarding for now. --- ### Task 1: Server-only user-service client and local profile projection **Files:** - Create: `lib/auth/user-service.ts`, `lib/auth/user-service.test.ts`, `db/migrations/003_external_user_profiles.sql` - Modify: `lib/db/users.ts`, `lib/db/users.test.ts`, `lib/auth/types.ts` **Interfaces:** - Produces `UserServiceProfile`, `UserServiceAuthError`, and `authenticateWithUserService(identifier, password, signal?): Promise`. - Produces `upsertExternalUser(database, profile): Promise`. - `AuthIdentity` contains local `id`, `externalUserId`, `login`, `fullName`, `position`, `email`, `firstName`, `lastName`, `middleName`, `roles`, and `lastModelId`. - [ ] **Step 1: Write failing client and repository tests** ```ts it("posts identifier and password to the configured user-service", async () => { fetchMock.mockResolvedValue(Response.json(validProfile)); await authenticateWithUserService("alice@example.com", "secret"); expect(fetchMock).toHaveBeenCalledWith( "http://user-service:8080/api/user/auth", expect.objectContaining({ method: "POST", body: JSON.stringify({ identifier: "alice@example.com", password: "secret" }) }), ); }); it("upserts by external ID while preserving model preference", async () => { await upsertExternalUser(database, validProfile); expect(database.queries[0].text).toContain("ON CONFLICT (external_user_id)"); expect(database.queries[0].text).not.toContain("last_model_id = EXCLUDED"); }); ``` - [ ] **Step 2: Run tests and confirm RED** Run: `npm test -- lib/auth/user-service.test.ts lib/db/users.test.ts` Expected: FAIL because the new client and upsert do not exist. - [ ] **Step 3: Add migration and profile types** ```sql ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; ALTER TABLE users ADD COLUMN external_user_id bigint UNIQUE; ALTER TABLE users ADD COLUMN login text; ALTER TABLE users ADD COLUMN full_name text; ALTER TABLE users ADD COLUMN position text; ALTER TABLE users ADD COLUMN first_name text; ALTER TABLE users ADD COLUMN last_name text; ALTER TABLE users ADD COLUMN middle_name text NOT NULL DEFAULT ''; ALTER TABLE users ADD COLUMN roles jsonb NOT NULL DEFAULT '[]'::jsonb; ``` - [ ] **Step 4: Implement strict upstream parsing and timeout** ```ts const response = await fetch(`${normalizedBaseUrl}/api/user/auth`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ identifier, password }), signal: AbortSignal.any([signal ?? new AbortController().signal, AbortSignal.timeout(5_000)]), cache: "no-store", }); ``` Validate every required field, positive numeric `id`, `is_active === true`, and `roles` as strings. Map upstream 401 to `UserServiceAuthError("invalid_credentials")`; map all other non-2xx, timeout, transport and schema errors to `UserServiceAuthError("unavailable")`. - [ ] **Step 5: Implement projection upsert** ```sql INSERT INTO users (external_user_id, email, login, full_name, position, first_name, last_name, middle_name, roles) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb) ON CONFLICT (external_user_id) DO UPDATE SET email=EXCLUDED.email, login=EXCLUDED.login, full_name=EXCLUDED.full_name, position=EXCLUDED.position, first_name=EXCLUDED.first_name, last_name=EXCLUDED.last_name, middle_name=EXCLUDED.middle_name, roles=EXCLUDED.roles, updated_at=now() RETURNING ...; ``` - [ ] **Step 6: Run focused verification** Run: `npm test -- lib/auth/user-service.test.ts lib/db/users.test.ts && npx tsc --noEmit` Expected: PASS. ### Task 2: Login route, JWT identity and personalized UI **Files:** - Modify: `lib/auth/server.ts`, `lib/auth/server.test.ts`, `app/api/auth/login/route.ts`, `app/api/auth/me/route.ts`, `app/api/auth/routes.test.ts`, `components/auth/login-screen.tsx`, `components/auth/auth-gate.tsx`, `components/chat/chat-sidebar.tsx`, `components/chat/chat-empty-state.tsx`, `components/chat/chat-app.tsx` - Test: `components/auth/auth-gate.test.tsx`, `components/chat/chat-app.test.tsx` **Interfaces:** - `/api/auth/login` accepts `{ email: string, password: string }` for backward-compatible browser payloads but treats `email` as the user-service `identifier`. - Login returns `{ token, user: AuthIdentity }`; `/api/auth/me` returns the same complete identity. - [ ] **Step 1: Write failing route and personalization tests** ```ts it("authenticates upstream, upserts the profile and issues an app JWT", async () => { authenticateWithUserService.mockResolvedValue(validProfile); upsertExternalUser.mockResolvedValue(localUser); const response = await POST(loginRequest({ email: "alice", password: "secret" })); expect(response.status).toBe(200); expect((await response.json()).user).toMatchObject({ fullName: "Alice Smith", roles: ["admin"] }); }); it("renders the employee name, position and roles", () => { render(); expect(screen.getByText("Alice Smith")).toBeInTheDocument(); expect(screen.getByText("Engineer")).toBeInTheDocument(); expect(screen.getByText("admin")).toBeInTheDocument(); }); ``` - [ ] **Step 2: Run tests and confirm RED** Run: `npm test -- app/api/auth/routes.test.ts components/auth/auth-gate.test.tsx components/chat/chat-app.test.tsx` Expected: FAIL because the route still uses local bcrypt authentication and the UI lacks profile fields. - [ ] **Step 3: Replace login authentication flow** ```ts const profile = await authenticateWithUserService(body.email, body.password, request.signal); const databaseUser = await upsertExternalUser(getPool(), profile); const user = toAuthIdentity(databaseUser); const token = await signAuthToken(user); ``` Return generic 401 for `invalid_credentials` and 503 for `unavailable`; preserve `Cache-Control: no-store`. Remove local bcrypt credential lookup from the login path. - [ ] **Step 4: Return and render complete identity** Use `fullName`, `position`, `roles` and initials from `firstName`/`lastName` in the sidebar. Change the login label to `Логин или почта`, use `autoComplete="username"`, and greet `firstName` in the empty state. Keep email as secondary text. - [ ] **Step 5: Run focused verification** Run: `npm test -- app/api/auth/routes.test.ts lib/auth/server.test.ts components/auth/auth-gate.test.tsx components/chat/chat-app.test.tsx && npm run lint && npx tsc --noEmit` Expected: PASS. ### Task 3: Kubernetes resources for the complete chat stack **Files:** - Create: `k8s/namespace.yaml`, `k8s/config.yaml`, `k8s/postgres.yaml`, `k8s/minio.yaml`, `k8s/app.yaml`, `k8s/README.md` - Modify: `Dockerfile`, `docker-entrypoint.sh`, `.env.example`, `README.md` **Interfaces:** - Produces Service `ai-control-chat-ui:3000`, PostgreSQL service `ai-control-chat-postgres:5432`, and MinIO service `ai-control-chat-minio:9000` in namespace `aegida-services`. - ConfigMap defines `USER_SERVICE_URL=http://user-service.aegida-services.svc.cluster.local:8080`. - [ ] **Step 1: Add manifest validation script expectation** ```bash kubectl apply --dry-run=client -f k8s/namespace.yaml kubectl apply --dry-run=client -f k8s/config.yaml kubectl apply --dry-run=client -f k8s/postgres.yaml kubectl apply --dry-run=client -f k8s/minio.yaml kubectl apply --dry-run=client -f k8s/app.yaml ``` - [ ] **Step 2: Create Secret and ConfigMap manifests** Use `stringData` for development-only JWT, database and MinIO credentials. Set `DATABASE_URL`, all S3 variables, `AI_GATEWAY_MOCK=true`, `AUTH_SEED_ENABLED=false`, and the internal user-service URL. Do not add an Ingress for localhost-only access. - [ ] **Step 3: Create stateful dependency manifests** PostgreSQL uses `postgres:16-alpine`, a 1 Gi PVC, readiness via `pg_isready`, and ClusterIP. MinIO uses `minio/minio:RELEASE.2025-04-22T22-12-26Z`, a 2 Gi PVC, private credentials, readiness endpoint `/minio/health/ready`, API port 9000 and console port 9001. - [ ] **Step 4: Create application Deployment and Service** Use image `ai-control-chat-ui:minikube`, `imagePullPolicy: IfNotPresent`, one replica, `envFrom` ConfigMap/Secret, readiness and liveness HTTP probes against `/api/health`, and ClusterIP port 3000. Add `/api/health` returning 200 only when the process is ready. - [ ] **Step 5: Validate manifests and production build** Run: `kubectl apply --dry-run=client -f k8s/ && npm run build` Expected: all objects validate and Next.js build passes. ### Task 4: Build, deploy, verify and expose on localhost **Files:** - Modify only if deployment evidence reveals a defect in files owned by Tasks 1–3. **Interfaces:** - Consumes all previous tasks. - Produces a running minikube Deployment and active localhost port-forward. - [ ] **Step 1: Run the full local suite** Run: `npm test && npm run lint && npx tsc --noEmit && npm run build` Expected: PASS. - [ ] **Step 2: Build the image inside minikube** Run: `minikube image build -t ai-control-chat-ui:minikube .` Expected: image build succeeds and `minikube image ls` contains `ai-control-chat-ui:minikube`. - [ ] **Step 3: Apply manifests and wait for rollouts** ```bash kubectl apply -f k8s/ kubectl -n aegida-services rollout status statefulset/ai-control-chat-postgres --timeout=180s kubectl -n aegida-services rollout status deployment/ai-control-chat-minio --timeout=180s kubectl -n aegida-services rollout status deployment/ai-control-chat-ui --timeout=180s ``` - [ ] **Step 4: Inspect readiness and dependency connectivity** Run: `kubectl -n aegida-services get pods,svc,pvc -o wide` and `kubectl -n aegida-services logs deployment/ai-control-chat-ui --tail=100`. Expected: all containers Ready, PVCs Bound, migrations complete, bucket ready, no crash loops. - [ ] **Step 5: Start localhost access and verify API** Run a persistent process: `kubectl -n aegida-services port-forward service/ai-control-chat-ui 3000:3000`. Verify: `curl --fail http://localhost:3000/api/health`, authenticate as `test-admin` / `AdminTest!2026`, then call `/api/auth/me` with the returned Bearer token and confirm `fullName`, `position`, and `roles`. - [ ] **Step 6: Report handoff** Provide the working URL, test account used, pod/service status, verification results, port-forward process identifier, and commands to stop/restart access.