# Corp UI Aegida Gate Migration 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:** Move `corp-ui` from its private gateway/S3 contracts to authenticated OpenAI-compatible `aegida-gate` model, chat, Files API, and effective-quota contracts while preserving the existing local conversation UX. **Architecture:** Keep same-origin Next.js routes as a browser/session boundary. They relay the user's JWT to `aegida-gate`, translate OpenAI SSE back into the UI's existing plain-text stream, persist only conversation metadata plus opaque gate file IDs, and expose safe model/quota proxies to client hooks. **Tech Stack:** Next.js 16, React 19, TypeScript, Vitest/Testing Library, PostgreSQL migrations, `jose` HS256 JWTs. ## Global Constraints - Work directly on the user-approved `main` branch; do not create a worktree. - Before writing code, read the relevant Next.js 16 guides under `node_modules/next/dist/docs/` as required by `AGENTS.md`; cite the files read in each task report. - Use strict TDD for every production behavior. - Preserve and do not overwrite pre-existing uncommitted attachment work. Before each task, compare `git status` and the task's target files; if a target was already dirty at plan start, stop that task rather than staging the user's changes. - The plan-start dirty paths are `.env.example`, `README.md`, `app/api/chat/route.test.ts`, `hooks/use-chat.test.tsx`, and untracked `docker-compose.yml`, attachment/storage/migration tests, and `scripts/smoke-db.sh`. Treat them as user-owned until explicitly cleared or authorized. - Browser code calls only same-origin `/api/...`; only server routes know `AEGIDA_GATE_URL`. - Server routes relay the exact incoming user Bearer token. They never substitute a shared gateway key for user requests. - JWTs require exact `JWT_ISSUER`, `JWT_AUDIENCE`, `AEGIDA_TENANT_ID`, `tenant_id`, and canonical decimal `external_user_id`; HS256 remains the initial algorithm. - Default/fallback model is exactly `auto`; arbitrary model IDs must match `^[a-z0-9][a-z0-9._-]{0,127}$` rather than a hard-coded union. - Chat uses `POST /v1/chat/completions` with `stream:true` and parses only OpenAI SSE `data:` JSON plus `[DONE]`. - Attachments use two steps: gate `POST /v1/files` with `purpose=user_data`, then native `file_id` content parts in Chat. No presigned S3 URL is sent to inference. - Quota is informational. Disable send only for an explicit `exhausted=true`; a quota dependency error remains nonfatal UI state. - Preserve local conversation IDs/message persistence and the existing `X-Conversation-Id`, `X-User-Message-Id`, and `X-Assistant-Message-Id` browser contract. - Do not stage files outside `corp-ui` or any pre-existing user changes not authored by the current task. --- ## File Structure - `lib/auth/types.ts`, `lib/auth/server.ts`: extended JWT identity contract. - `app/api/auth/login/route.ts`: signs the full gate identity. - `db/migrations/004_aegida_gate_models.sql`: remove static model checks/default to `auto`. - `db/migrations/005_gate_file_ids.sql`: retain local attachment metadata with opaque gate IDs. - `lib/chat/types.ts`, `lib/chat/models.ts`, `lib/chat/validation.ts`: dynamic model IDs/catalog types. - `lib/chat/gateway.ts`: OpenAI request/SSE adapter. - `lib/chat/gate-files.ts`: server-only Files API client. - `lib/chat/quota.ts`: server-only quota client. - `app/api/models/route.ts`, `app/api/me/quota/route.ts`: safe same-origin proxies. - `app/api/attachments/route.ts`, `app/api/attachments/[id]/route.ts`: gate upload/content proxies. - `lib/db/attachments.ts`, `lib/db/conversations.ts`: local metadata and file reference resolution. - `app/api/chat/route.ts`: forwards JWT and native file references. - `hooks/use-models.ts`, `hooks/use-chat.ts`: catalog/quota lifecycle. - `components/chat/model-selector.tsx`, `chat-header.tsx`, `chat-app.tsx`, `chat-composer.tsx`: dynamic models and balance UI. - `.env.example`, `README.md`, `k8s/config.yaml`, `k8s/app.yaml`, `docker-compose.yml`: deployment contract. ### Task 1: Issue gate-verifiable user JWTs **Files:** - Modify: `lib/auth/types.ts` - Modify: `lib/auth/types.test.ts` - Modify: `lib/auth/server.ts` - Modify: `lib/auth/server.test.ts` - Modify: `app/api/auth/login/route.ts` - Modify: `app/api/auth/routes.test.ts` **Interfaces:** - Produces: `AuthTokenIdentity` with internal/external identity and tenant - Preserves browser-visible `AuthIdentity` - Produces gate-compatible HS256 issuer/audience claims - [ ] **Step 1: Read the Next.js route-handler/environment guides** Read the complete relevant files found under `node_modules/next/dist/docs/` for Route Handlers and environment variables. Record their exact paths in the report before editing. - [ ] **Step 2: Add failing JWT claim tests** With fixed environment values, sign a real token and verify its claims are exactly: ```json { "sub": "11111111-1111-4111-8111-111111111111", "email": "user@example.corp", "tenant_id": "tenant-1", "external_user_id": "42", "iss": "corp-ui", "aud": "aegida-gate" } ``` plus numeric `iat`/`exp`. Tests reject wrong/missing issuer, audience, tenant, external ID, algorithm, signature, and expiry. Login-route tests prove the `AuthIdentity.externalUserId` returned from the DB is passed into signing and the token body is not logged. - [ ] **Step 3: Run auth tests and confirm RED** Run: `npm test -- lib/auth/types.test.ts lib/auth/server.test.ts app/api/auth/routes.test.ts` Expected: missing claims and issuer/audience validation. - [ ] **Step 4: Implement the extended token contract** Define: ```ts export type AuthTokenIdentity = Pick & { tenantId: string; }; ``` `signAuthToken` receives the authenticated user plus `AEGIDA_TENANT_ID`, sets protected HS256, subject, issuer, audience, issued/expiry, and private `tenant_id`/`external_user_id`. `verifyAuthToken` passes `issuer`, `audience`, and `algorithms:["HS256"]` to `jwtVerify`, validates all fields, and returns them. `JWT_SECRET` remains required and must be at least 32 UTF-8 bytes. - [ ] **Step 5: Verify and commit Task 1** Run: `npm test -- lib/auth/types.test.ts lib/auth/server.test.ts app/api/auth/routes.test.ts` ```bash git add lib/auth/types.ts lib/auth/types.test.ts lib/auth/server.ts lib/auth/server.test.ts app/api/auth/login/route.ts app/api/auth/routes.test.ts git commit -m "feat: issue aegida gate identity claims" ``` ### Task 2: Discover models dynamically and persist stable slugs **Files:** - Create: `db/migrations/004_aegida_gate_models.sql` - Modify: `lib/chat/types.ts` - Modify: `lib/chat/models.ts` - Modify: `lib/chat/validation.ts` - Modify: `lib/chat/validation.test.ts` - Create: `lib/chat/catalog.ts` - Create: `lib/chat/catalog.test.ts` - Create: `app/api/models/route.ts` - Create: `app/api/models/route.test.ts` - Create: `hooks/use-models.ts` - Create: `hooks/use-models.test.tsx` - Modify: `components/chat/model-selector.tsx` - Modify: `components/chat/chat-header.tsx` - Modify: `components/chat/chat-app.tsx` - Modify: `app/api/me/model/route.ts` - Modify: `lib/auth/types.ts` **Interfaces:** - Produces: `type ModelId = string` plus `isModelId` - Produces: `GET /api/models` safe proxy and `useModels` - Produces: dynamic `ModelSelector` props - [ ] **Step 1: Read Next.js data-fetching and Route Handler docs** Read the relevant complete local Next 16 documentation and record paths in the report. - [ ] **Step 2: Add failing model validation/migration tests** Tests prove `auto`, `qwen`, and `claude-sonnet.4` are valid; uppercase, slash, leading separator, empty, and 129-byte IDs fail. Migration tests assert both users/conversations default to `auto`, existing `chatgpt|deepseek|qwen` values survive, and named static `CHECK` constraints are replaced with slug-format checks. - [ ] **Step 3: Run focused tests and confirm RED** Run: `npm test -- lib/chat/validation.test.ts lib/db/migrate.test.ts` Expected: static union/set rejects dynamic IDs and migration is absent. - [ ] **Step 4: Implement dynamic ID types and migration** Export `isModelId(value): value is ModelId` with the exact slug rule and reuse it in auth/chat/preference validation. `004` discovers/drops the existing generated check constraints by table/column from `pg_constraint`, installs explicitly named checks, changes defaults to `auto`, and is idempotent through the existing migration runner. - [ ] **Step 5: Add failing catalog proxy/hook/component tests** Tests assert `/api/models` authenticates, relays the exact user Bearer token to `${AEGIDA_GATE_URL}/v1/models`, uses `cache:no-store`, strictly parses OpenAI `{object:"list",data:[...]}`, removes malformed/duplicate IDs, and guarantees one `auto`. Upstream auth failure remains `401`; other failures return generic `503` without body leakage. `useModels` loads once per authenticated user, cancels stale requests, falls back to only `{id:"auto",name:"Aegida Auto"}`, and selects `auto` if a persisted model is absent. `ModelSelector` renders passed models rather than `MODELS` globals and retains keyboard accessibility. - [ ] **Step 6: Run tests and confirm RED** Run: `npm test -- lib/chat/catalog.test.ts app/api/models/route.test.ts hooks/use-models.test.tsx components/chat/chat-app.test.tsx` Expected: proxy/hook absent and selector remains static. - [ ] **Step 7: Implement catalog proxy and dynamic UI** `catalog.ts` contains only server-safe parsing and `toChatModel`; descriptions derive from safe capabilities/owner with a generic fallback, never provider config. `ChatApp` combines `useModels` and `useChat`; model persistence accepts any validated current catalog ID, with server-side catalog confirmation before DB update. - [ ] **Step 8: Verify and commit Task 2** Run: `npm test -- lib/chat lib/auth/types.test.ts app/api/models hooks/use-models.test.tsx components/chat/chat-app.test.tsx` ```bash git add db/migrations/004_aegida_gate_models.sql lib/chat/types.ts lib/chat/models.ts lib/chat/validation.ts lib/chat/validation.test.ts lib/chat/catalog.ts lib/chat/catalog.test.ts app/api/models hooks/use-models.ts hooks/use-models.test.tsx components/chat/model-selector.tsx components/chat/chat-header.tsx components/chat/chat-app.tsx app/api/me/model/route.ts lib/auth/types.ts git commit -m "feat: discover aegida gate models" ``` ### Task 3: Translate OpenAI Chat SSE while preserving the browser stream **Files:** - Modify: `lib/chat/gateway.ts` - Modify: `lib/chat/gateway.test.ts` - Modify: `lib/chat/types.ts` - Modify: `app/api/chat/route.ts` - Modify: `app/api/chat/route.test.ts` **Interfaces:** - Produces: `streamGatewayResponse(request, authorization, signal)` - Consumes: `POST /v1/chat/completions` OpenAI SSE - Preserves: plain UTF-8 stream returned by `/api/chat` - [ ] **Step 1: Confirm dirty-file ownership before editing** `app/api/chat/route.test.ts` was dirty at plan creation. If it is still dirty and the changes are not already committed by the user, stop this task and report the exact overlap. Do not stage it implicitly. - [ ] **Step 2: Read Next.js streaming Route Handler docs** Read the complete local Next 16 streaming/Route Handler references and record paths. - [ ] **Step 3: Add failing OpenAI adapter tests** Assert the exact upstream request: ```json { "model": "auto", "stream": true, "stream_options": {"include_usage": true}, "messages": [{"role":"user","content":"hello"}] } ``` at `${AEGIDA_GATE_URL}/v1/chat/completions`, with the exact incoming Authorization header and no service API key. Feed arbitrarily split SSE bytes containing comments, multiple `data:` lines, JSON delta chunks, a usage/finish chunk, and `[DONE]`; assert the returned stream contains only concatenated `choices[].delta.content`. Malformed JSON, OpenAI error envelope, EOF before `[DONE]`, non-2xx, oversize event, cancellation, and prompt/body leakage map to safe `GatewayError`/abort behavior. - [ ] **Step 4: Run gateway tests and confirm RED** Run: `npm test -- lib/chat/gateway.test.ts app/api/chat/route.test.ts` Expected: private payload/plain stream behavior fails expectations. - [ ] **Step 5: Implement the OpenAI SSE adapter** Replace `AI_GATEWAY_URL` with validated `AEGIDA_GATE_URL` base semantics. Implement an incremental SSE parser over `ReadableStream` without buffering the entire response, cap each event at 1 MiB, decode exact OpenAI delta shapes, ignore usage-only chunks, and require `[DONE]`. `streamGatewayResponse` requires the caller's canonical Bearer header. In `/api/chat`, retain the original Authorization value after `authenticateRequest`, pass it to the adapter, and keep persistence/browser headers unchanged. Remove private `attachments[].url` construction; Task 4 supplies native file parts. - [ ] **Step 6: Verify and commit Task 3** Run: `npm test -- lib/chat/gateway.test.ts app/api/chat/route.test.ts hooks/use-chat.test.tsx` Stage only task-authored hunks. If that cannot be proven for the dirty tests, stop rather than commit user work. ```bash git add lib/chat/gateway.ts lib/chat/gateway.test.ts lib/chat/types.ts app/api/chat/route.ts git commit -m "feat: stream chat through aegida gate" ``` ### Task 4: Store attachments through the gate Files API **Files:** - Create: `db/migrations/005_gate_file_ids.sql` - Create: `lib/chat/gate-files.ts` - Create: `lib/chat/gate-files.test.ts` - Modify: `lib/db/attachments.ts` - Modify: `lib/db/attachments.test.ts` - Modify: `lib/db/conversations.ts` - Modify: `app/api/attachments/route.ts` - Modify: `app/api/attachments/[id]/route.ts` - Modify: `app/api/attachments/[id]/route.test.ts` - Modify: `app/api/chat/route.ts` - Modify: `app/api/chat/route.test.ts` - Modify: `lib/chat/types.ts` **Interfaces:** - Produces: gate upload/get-content/delete client using user Bearer - Produces local attachment metadata with opaque `gate_file_id` - Produces OpenAI native `type:"file"` content blocks - [ ] **Step 1: Confirm dirty-file ownership before editing** Several attachment tests and `app/api/chat/route.test.ts` were dirty/untracked at plan creation. If they remain user-owned, stop and report the overlap. Never delete or absorb the existing S3 work into a task commit without explicit authorization. - [ ] **Step 2: Add failing Files client tests** Assert upload forwards multipart `file` and `purpose:user_data` to `${AEGIDA_GATE_URL}/v1/files`, exact user Authorization, request cancellation, and no whole-file `arrayBuffer` conversion. Strictly parse OpenAI FileObject `file-` ID/name/bytes. Content/delete methods use authenticated standard paths. Non-2xx, oversized JSON, malformed ID, and dependency body sentinels map to safe errors. - [ ] **Step 3: Add failing DB migration/metadata tests** `005` adds unique non-null `gate_file_id` for new rows, makes legacy `object_key` nullable, and retains old rows for download compatibility. New create/attach/get functions return `{id,gateFileId,name,contentType,size}` with ownership predicates. Tests prove DB failure after gate upload triggers authenticated gate delete compensation. - [ ] **Step 4: Run focused tests and confirm RED** Run: `npm test -- lib/chat/gate-files.test.ts lib/db/attachments.test.ts lib/db/migrate.test.ts app/api/attachments` Expected: gate client/schema absent. - [ ] **Step 5: Implement upload/content proxy and native chat references** Upload route authenticates, locally validates current UI limits, streams the original `File` to Gate, then persists only returned metadata/ID. Download route resolves the owned row and streams Gate content with the same Authorization; legacy object-key rows continue through the old path during migration. When attaching to a message, `app/api/chat` builds the latest user message content as: ```json [ {"type":"text","text":"optional text"}, {"type":"file","file":{"file_id":"file-opaque"}} ] ``` Earlier text-only history stays string content. Attachment-only requests omit the text block. No object key or presigned URL leaves corp-ui. - [ ] **Step 6: Verify and commit Task 4** Run: `npm test -- lib/chat/gate-files.test.ts lib/db app/api/attachments app/api/chat/route.test.ts components/chat/chat-message.test.tsx` Stage only known task-authored files/hunks. ```bash git add db/migrations/005_gate_file_ids.sql lib/chat/gate-files.ts lib/chat/gate-files.test.ts lib/db/attachments.ts lib/db/conversations.ts app/api/attachments app/api/chat/route.ts lib/chat/types.ts git commit -m "feat: store attachments in aegida gate" ``` ### Task 5: Display effective token availability **Files:** - Create: `lib/chat/quota.ts` - Create: `lib/chat/quota.test.ts` - Create: `app/api/me/quota/route.ts` - Create: `app/api/me/quota/route.test.ts` - Modify: `hooks/use-chat.ts` - Modify: `hooks/use-chat.test.tsx` - Modify: `components/chat/chat-header.tsx` - Modify: `components/chat/chat-app.tsx` - Modify: `components/chat/chat-composer.tsx` - Modify: `components/chat/chat-app.test.tsx` **Interfaces:** - Produces safe same-origin `GET /api/me/quota?model=...` - Produces `UseChatResult.quota` and refresh lifecycle - Consumes Aegida quota snapshot, never FinOps directly - [ ] **Step 1: Confirm dirty hook ownership and read data-fetching docs** `hooks/use-chat.test.tsx` was dirty at plan creation. Stop if still user-owned. Read the complete local Next/React data-fetching guidance relevant to cancellable client requests. - [ ] **Step 2: Add failing quota parser/proxy tests** Strict parser accepts only the approved object/model/token/unlimited/exhausted/reset/as-of relationships. Proxy authenticates, validates model slug, relays user Bearer to `/v1/aegida/quota`, disables caching, maps upstream `401` to `401`, and maps every other malformed/dependency response to generic `503` without body leakage. - [ ] **Step 3: Add failing hook/UI lifecycle tests** Assert quota refresh on initial effective model, successful model switch, and completion/error of a chat turn. Abort stale model requests so a slower old model cannot overwrite current state. `available_tokens` formats with locale separators, unlimited displays `Без лимита`, nullable reset is omitted, dependency failure displays `Баланс недоступен` but leaves send enabled, and explicit exhausted disables send with an explanatory label. - [ ] **Step 4: Run tests and confirm RED** Run: `npm test -- lib/chat/quota.test.ts app/api/me/quota/route.test.ts hooks/use-chat.test.tsx components/chat/chat-app.test.tsx` Expected: quota route/state/UI absent. - [ ] **Step 5: Implement quota proxy, hook state, and UI** Keep quota as `{status:"loading"|"ready"|"unavailable", snapshot?:...}`. Use an incrementing request generation plus AbortController. Do not optimistically decrement client-side; refresh the authoritative snapshot after each turn. Pass quota to header/composer and disable only for `snapshot.exhausted === true`. - [ ] **Step 6: Verify and commit Task 5** Run: `npm test -- lib/chat/quota.test.ts app/api/me/quota/route.test.ts hooks/use-chat.test.tsx components/chat` ```bash git add lib/chat/quota.ts lib/chat/quota.test.ts app/api/me/quota hooks/use-chat.ts components/chat/chat-header.tsx components/chat/chat-app.tsx components/chat/chat-composer.tsx git commit -m "feat: show effective token availability" ``` ### Task 6: Update deployment/docs and run full UI verification **Files:** - Modify: `.env.example` - Modify: `README.md` - Modify: `k8s/config.yaml` - Modify: `k8s/app.yaml` - Modify: `docker-compose.yml` - Modify or remove only after ownership approval: direct S3/MinIO runtime wiring no longer needed by new uploads **Interfaces:** - Produces documented `AEGIDA_GATE_URL`, JWT issuer/audience/tenant settings - Preserves local PostgreSQL conversation deployment - [ ] **Step 1: Confirm dirty documentation/compose ownership** `.env.example`, `README.md`, and untracked `docker-compose.yml` were dirty at plan creation. Stop if still user-owned and explicit authorization to merge those changes has not been obtained. - [ ] **Step 2: Add/update configuration contract tests** Tests or build-time checks prove server-only `AEGIDA_GATE_URL` is required in non-mock production, issuer/audience/tenant are configured consistently with Gate, no `NEXT_PUBLIC_` gate secret/base URL is emitted, and legacy `AI_GATEWAY_API_KEY` is unused. - [ ] **Step 3: Document and configure the migration** Document dynamic models, `auto`, provider-compatible chat/SSE translation, the two-step Files flow, effective quota semantics, JWT claim coordination, and legacy attachment-row compatibility. K8s/Compose use the Gate service DNS/base URL and shared JWT settings; direct MinIO remains only if needed for legacy attachment downloads during retention. - [ ] **Step 4: Run full verification** Run: ```bash npm test npm run lint npm run build ./scripts/smoke-db.sh git diff --check ``` Expected: all commands exit 0. The DB smoke may skip only through its existing documented dependency guard. - [ ] **Step 5: Commit Task 6** Stage only task-authored hunks after the dirty-file ownership check. ```bash git add .env.example README.md k8s docker-compose.yml git commit -m "docs: configure corp ui for aegida gate" ```