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: Build a ChatGPT-style corporate chat UI with one-day header-based JWT authentication, local chat history, three model routes, and a single streaming AI Gateway adapter.
Architecture: A client-side auth gate validates a JWT from localStorage against protected Next.js Route Handlers before rendering the chat. Server-only auth and gateway modules normalize security, validation, and streaming while focused React components own the responsive chat UI and device-local state.
Tech Stack: Next.js 16.2, React 19.2, TypeScript 5, Tailwind CSS 4, shadcn/Base UI, Lucide React, jose, Vitest, React Testing Library, JSDOM.
GatewayAdapter; provider-specific logic must not exist in this application.chatgpt, deepseek, and qwen.Authorization: Bearer <JWT>, and expires exactly 24 hours after issue.ai-control-center:auth-token; do not use cookies.demo@ai-control.local and Demo1234!, supplied through server-only environment variables.lib/auth/types.ts: serializable public user identity shared by server and client.lib/auth/server.ts: JWT signing, verification, credentials comparison, and Bearer extraction.lib/auth/client.ts: browser token persistence and authenticated fetch helper.app/api/auth/login/route.ts: credential exchange for a one-day JWT.app/api/auth/me/route.ts: protected identity probe used during startup.components/auth/auth-gate.tsx: startup auth state machine and login/chat switching.components/auth/login-screen.tsx: accessible corporate login form.lib/chat/types.ts: shared model, message, conversation, and request types.lib/chat/models.ts: model display configuration only.lib/chat/storage.ts: validated, user-namespaced localStorage persistence.lib/chat/validation.ts: runtime validation and request limits.lib/chat/gateway.ts: single HTTP/mock streaming adapter.app/api/chat/route.ts: authenticated validated streaming endpoint.hooks/use-chat.ts: chat lifecycle, streaming, cancellation, retry, and persistence.components/chat/*: focused sidebar, header, empty state, message list, and composer views.components/ui/input.tsx, components/ui/textarea.tsx: missing shadcn form primitives.app/page.tsx: thin entry point that renders the auth gate.app/globals.css: product visual system and responsive base behavior.app/layout.tsx: Russian metadata and document language.next.config.ts: CSP and security response headers.Files:
package.jsonpackage-lock.json.gitignore.env.example.env.localvitest.config.mtsvitest.setup.tslib/auth/types.tslib/auth/server.tslib/auth/server.test.tsapp/api/auth/login/route.tsapp/api/auth/me/route.tsapp/api/auth/routes.test.tsInterfaces:
Produces: AuthIdentity, VerifiedAuthToken, signAuthToken(user), verifyAuthToken(token), authenticateRequest(request), and JSON auth routes.
Consumes: server-only AUTH_USER_ID, AUTH_EMAIL, AUTH_PASSWORD, and JWT_SECRET.
Step 1: Install auth and test dependencies and add scripts
Run:
npm install jose server-only
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event vite-tsconfig-paths
Add scripts:
"test": "vitest run",
"test:watch": "vitest"
Create vitest.config.mts:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
clearMocks: true,
},
});
Create vitest.setup.ts:
import "@testing-library/jest-dom/vitest";
Add !.env.example after .env* in .gitignore. Commit this template:
AUTH_USER_ID=demo-user
AUTH_EMAIL=demo@ai-control.local
AUTH_PASSWORD=change-me
JWT_SECRET=replace-with-at-least-32-random-characters
AI_GATEWAY_MOCK=true
AI_GATEWAY_URL=
AI_GATEWAY_API_KEY=
Create the ignored .env.local for immediate local use:
AUTH_USER_ID=demo-user
AUTH_EMAIL=demo@ai-control.local
AUTH_PASSWORD=Demo1234!
JWT_SECRET=ai-control-center-local-development-secret-2026
AI_GATEWAY_MOCK=true
Create tests that pin the public API:
it("issues an HS256 token for exactly 24 hours", async () => {
const token = await signAuthToken({ id: "demo-user", email: "demo@ai-control.local" });
const user = await verifyAuthToken(token);
expect(user).toMatchObject({ id: "demo-user", email: "demo@ai-control.local" });
expect(user.exp - user.iat).toBe(86_400);
});
it("rejects missing and malformed bearer authorization", async () => {
await expect(authenticateRequest(new Request("http://localhost/api/chat"))).rejects.toMatchObject({ status: 401 });
await expect(authenticateRequest(new Request("http://localhost/api/chat", {
headers: { Authorization: "Token invalid" },
}))).rejects.toMatchObject({ status: 401 });
});
Run: npm test -- lib/auth/server.test.ts
Expected: FAIL because lib/auth/server.ts does not exist.
Use jose with an explicit algorithm allowlist and stable claims:
import "server-only";
import { SignJWT, jwtVerify } from "jose";
import type { AuthIdentity } from "@/lib/auth/types";
export type VerifiedAuthToken = AuthIdentity & { iat: number; exp: number };
export async function signAuthToken(user: AuthIdentity): Promise<string> {
return new SignJWT({ email: user.email })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("24h")
.sign(new TextEncoder().encode(requireEnv("JWT_SECRET")));
}
export async function verifyAuthToken(token: string): Promise<VerifiedAuthToken> {
const { payload } = await jwtVerify(token, new TextEncoder().encode(requireEnv("JWT_SECRET")), {
algorithms: ["HS256"],
});
if (!payload.sub || typeof payload.email !== "string" || !payload.iat || !payload.exp) throw unauthorized();
return { id: payload.sub, email: payload.email, iat: payload.iat, exp: payload.exp };
}
Define AuthIdentity as { id: string; email: string } in lib/auth/types.ts. Add credentialsMatch(email, password) using constant-time string comparisons and authenticateRequest(request) requiring the exact case-insensitive Bearer scheme with a non-empty token. Normalize all verification failures to an AuthError with status 401.
Cover successful login, generic invalid-credentials response, malformed JSON, and /api/auth/me with a valid/invalid Bearer token:
const response = await login(new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "demo@ai-control.local", password: "Demo1234!" }),
}));
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ token: expect.any(String), user: { id: "demo-user" } });
POST /api/auth/login returns { token, user: { id, email } } on success, { error: "Неверная почта или пароль" } with 401 on mismatch, and { error: "Некорректный запрос" } with 400 on invalid JSON. GET /api/auth/me returns { user: { id, email } } after authenticateRequest and a generic 401 otherwise. Both responses use Cache-Control: no-store.
Run: npm test -- lib/auth/server.test.ts app/api/auth/routes.test.ts
Expected: PASS.
Commit:
git add package.json package-lock.json .gitignore .env.example vitest.config.mts vitest.setup.ts lib/auth app/api/auth
git commit -m "feat: add header-based JWT authentication"
Files:
lib/auth/client.tscomponents/auth/login-screen.tsxcomponents/auth/auth-gate.tsxcomponents/auth/auth-gate.test.tsxcomponents/chat/chat-app.tsx as the authenticated shell, expanded in Task 5components/ui/input.tsxapp/page.tsxInterfaces:
Consumes: GET /api/auth/me, POST /api/auth/login, AuthIdentity.
Produces: AuthGate, authFetch(input, init), getStoredToken(), clearStoredToken().
Step 1: Write failing auth-gate tests
Use fake localStorage and mocked fetch to pin three states:
it("never renders login while a stored token is being validated", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "valid-token");
vi.mocked(fetch).mockReturnValue(new Promise(() => undefined));
render(<AuthGate />);
expect(screen.getByLabelText("Проверяем доступ")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
});
it("opens the chat after a valid identity response", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "valid-token");
vi.mocked(fetch).mockResolvedValue(jsonResponse({ user: { id: "demo-user", email: "demo@ai-control.local" } }));
render(<AuthGate />);
expect(await screen.findByText("Новый чат")).toBeInTheDocument();
});
Also verify absent/invalid tokens show login, successful login stores the token, every identity request includes Authorization: Bearer valid-token, and logout removes it.
Run: npm test -- components/auth/auth-gate.test.tsx
Expected: FAIL because the auth client and components do not exist.
Create a focused module:
export const AUTH_TOKEN_KEY = "ai-control-center:auth-token";
export function getStoredToken() {
return window.localStorage.getItem(AUTH_TOKEN_KEY);
}
export async function authFetch(input: RequestInfo | URL, init: RequestInit = {}) {
const token = getStoredToken();
const headers = new Headers(init.headers);
if (token) headers.set("Authorization", `Bearer ${token}`);
return fetch(input, { ...init, headers });
}
Also implement storeToken and clearStoredToken without accessing window at module initialization.
AuthGate is a client component with checking, anonymous, and authenticated states. On mount it validates any stored token through authFetch("/api/auth/me", { cache: "no-store" }); 401 clears storage. Successful login stores the returned token and user. Logout clears the token and in-memory user.
LoginScreen uses labeled email/password inputs, autoComplete, submit loading state, disabled empty submit, and an aria-live="polite" generic error. The heading is Вход, the product name is AI Control Center, and the primary action is Продолжить.
Create the initial ChatApp({ user, onLogout }) authenticated shell with the visible Новый чат label and a working logout action. Task 5 replaces its body with the complete chat experience without changing its public props.
Run: npm test -- components/auth/auth-gate.test.tsx
Expected: PASS.
Commit:
git add lib/auth/client.ts components/auth components/chat/chat-app.tsx components/ui/input.tsx app/page.tsx
git commit -m "feat: add persistent login experience"
Files:
lib/chat/types.tslib/chat/models.tslib/chat/storage.tslib/chat/storage.test.tsInterfaces:
Produces: ModelId, ChatMessage, Conversation, ChatRequest, MODELS, loadConversations(userId), saveConversations(userId, conversations).
Consumes: authenticated user.id from AuthGate.
Step 1: Write failing storage and model tests
it("namespaces conversations by authenticated user", () => {
saveConversations("user-a", [conversation]);
expect(loadConversations("user-a")).toEqual([conversation]);
expect(loadConversations("user-b")).toEqual([]);
});
it("discards malformed persisted data", () => {
localStorage.setItem(storageKey("demo-user"), "{not-json");
expect(loadConversations("demo-user")).toEqual([]);
});
it("contains only gateway route identifiers", () => {
expect(MODELS.map((model) => model.id)).toEqual(["chatgpt", "deepseek", "qwen"]);
});
Run: npm test -- lib/chat/storage.test.ts
Expected: FAIL because the chat domain modules do not exist.
export type ModelId = "chatgpt" | "deepseek" | "qwen";
export type MessageRole = "user" | "assistant";
export type MessageStatus = "streaming" | "complete" | "error" | "stopped";
export type ChatMessage = {
id: string;
role: MessageRole;
content: string;
createdAt: string;
status: MessageStatus;
};
export type Conversation = {
id: string;
title: string;
modelId: ModelId;
updatedAt: string;
messages: ChatMessage[];
};
Create MODELS with Russian descriptions: ChatGPT as a universal assistant, DeepSeek for reasoning/code, and Qwen for multilingual work. Do not include provider API endpoints or concrete upstream model versions.
Use ai-control-center:chats:${encodeURIComponent(userId)}. Parse unknown JSON with explicit shape checks for IDs, roles, statuses, dates, and supported model IDs. Sort loaded conversations newest-first, cap persistence at 50 conversations, and return [] on browser storage exceptions.
Run: npm test -- lib/chat/storage.test.ts
Expected: PASS.
Commit:
git add lib/chat
git commit -m "feat: add local chat domain"
Files:
lib/chat/validation.tslib/chat/validation.test.tslib/chat/gateway.tslib/chat/gateway.test.tsapp/api/chat/route.tsapp/api/chat/route.test.tsInterfaces:
Consumes: ChatRequest, authenticateRequest(request), AI_GATEWAY_URL, AI_GATEWAY_API_KEY, and AI_GATEWAY_MOCK.
Produces: validateChatRequest(value), streamGatewayResponse(request, signal), authenticated POST /api/chat text stream.
Step 1: Write failing request validation tests
Cover the happy path and each exact limit:
expect(validateChatRequest({ model: "chatgpt", messages: [{ role: "user", content: "Привет" }] })).toEqual({
model: "chatgpt",
messages: [{ role: "user", content: "Привет" }],
});
expect(() => validateChatRequest({ model: "other", messages: [] })).toThrow(ChatValidationError);
expect(() => validateChatRequest({ model: "qwen", messages: Array.from({ length: 101 }, () => ({ role: "user", content: "x" })) })).toThrow(ChatValidationError);
Also test a 32,001-character message and a 128,001-character total.
Run: npm test -- lib/chat/validation.test.ts
Expected: FAIL because validation is absent.
Reject non-objects, unknown models, empty arrays, unsupported roles, empty/whitespace-only content, more than 100 messages, more than 32,000 characters in one message, and more than 128,000 total characters. Return a new normalized object containing only model, role, and content.
it("sends one normalized request to the configured gateway", async () => {
process.env.AI_GATEWAY_URL = "https://gateway.example/v1/chat";
process.env.AI_GATEWAY_API_KEY = "gateway-secret";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("answer")));
const stream = await streamGatewayResponse(request, new AbortController().signal);
expect(fetch).toHaveBeenCalledWith("https://gateway.example/v1/chat", expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ Authorization: "Bearer gateway-secret" }),
body: JSON.stringify(request),
}));
expect(await new Response(stream).text()).toBe("answer");
});
Also verify the outgoing request never contains the user's JWT, forwards the provided AbortSignal, returns deterministic chunks in mock mode, emits 503 when unconfigured, and normalizes non-2xx gateway responses without leaking their body.
Define GatewayError with safe status and message. streamGatewayResponse(request, signal) selects HTTP when AI_GATEWAY_URL is present, otherwise mock only when AI_GATEWAY_MOCK === "true". HTTP sends JSON and an optional gateway key. Mock mode returns a UTF-8 ReadableStream whose Russian response mentions the selected display model and echoes no secrets; its timers stop when the signal aborts.
Verify 401 is returned before body validation, 400 for invalid authenticated payloads, 503 for missing transport, and a successful response has Content-Type: text/plain; charset=utf-8, Cache-Control: no-store, and streamed text.
export async function POST(request: Request) {
try {
await authenticateRequest(request);
const chatRequest = validateChatRequest(await request.json());
const stream = await streamGatewayResponse(chatRequest, request.signal);
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return chatErrorResponse(error);
}
}
Ensure JSON parse failures become 400, auth errors remain 401, configuration/gateway errors are safe 503/502, and unknown errors return generic 500.
Run: npm test -- lib/chat/validation.test.ts lib/chat/gateway.test.ts app/api/chat/route.test.ts
Expected: PASS.
Commit:
git add lib/chat app/api/chat
git commit -m "feat: add streaming AI Gateway adapter"
Files:
hooks/use-chat.tshooks/use-chat.test.tsxcomponents/chat/chat-app.tsxcomponents/chat/chat-app.test.tsxcomponents/chat/chat-sidebar.tsxcomponents/chat/chat-header.tsxcomponents/chat/model-selector.tsxcomponents/chat/chat-empty-state.tsxcomponents/chat/chat-message-list.tsxcomponents/chat/chat-message.tsxcomponents/chat/chat-composer.tsxcomponents/ui/textarea.tsxcomponents/auth/auth-gate.tsxInterfaces:
Consumes: authenticated user, authFetch, chat types, MODELS, storage helpers, and POST /api/chat text stream.
Produces: useChat(userId), ChatApp({ user, onLogout }), complete desktop/mobile chat UI.
Step 1: Write failing streaming hook tests
Use a ReadableStream test response and pin lifecycle behavior:
it("adds the user message and streams assistant chunks", async () => {
vi.mocked(authFetch).mockResolvedValue(streamResponse(["Привет", ", мир"]));
const { result } = renderHook(() => useChat("demo-user"));
await act(() => result.current.sendMessage("Тест"));
expect(result.current.activeConversation?.messages.map((message) => message.content)).toEqual(["Тест", "Привет, мир"]);
expect(result.current.activeConversation?.messages.at(-1)?.status).toBe("complete");
});
Also test first-message title truncation, model changes applying to later requests, AbortController.abort() on stop, retry replacing the errored assistant message, 401 calling onUnauthorized, storage persistence, and rejection of empty text.
Run: npm test -- hooks/use-chat.test.tsx
Expected: FAIL because useChat does not exist.
Return this stable interface:
type UseChatResult = {
conversations: Conversation[];
activeConversation: Conversation | null;
activeModelId: ModelId;
isGenerating: boolean;
selectConversation(id: string): void;
newConversation(): void;
deleteConversation(id: string): void;
setModel(id: ModelId): void;
sendMessage(content: string): Promise<void>;
stopGenerating(): void;
retryLast(): Promise<void>;
};
Use immutable state updates, crypto.randomUUID(), a retained AbortController, streaming response.body.getReader(), TextDecoder, and persistence after each meaningful state transition. Treat 401 as session loss and other failures as retryable assistant errors.
Verify the product renders a new-chat empty state, model selection, Enter send, Shift+Enter newline, disabled empty send, stop button during streaming, copy action, retry action, sidebar conversation selection/deletion, mobile menu toggle, and logout.
await user.type(screen.getByLabelText("Сообщение"), "Составь план{enter}");
expect(sendMessage).toHaveBeenCalledWith("Составь план");
expect(screen.getByRole("button", { name: "Остановить генерацию" })).toBeInTheDocument();
Use Button, Input, and Textarea shadcn primitives plus Lucide icons. The desktop sidebar is 272px wide; mobile uses an overlay drawer. The header contains a shadcn-style model trigger/menu and mobile menu action. The empty state says Чем я могу помочь? with three realistic prompt suggestions. User messages use subtle right-aligned bubbles; assistant messages use a clean full-width reading column with copy/retry controls. The composer is centered, grows up to 200px, and includes the model disclaimer.
Use real buttons with accessible names, focus-visible styling, aria-live="polite" for incoming text, and aria-busy during generation. Preserve whitespace with CSS instead of an HTML renderer.
Render <ChatApp user={user} onLogout={logout} /> only in AuthGate's authenticated state. Pass an onUnauthorized callback into useChat so a 401 clears the token and returns to login without rendering stale chat controls.
Run: npm test -- hooks/use-chat.test.tsx components/chat/chat-app.test.tsx components/auth/auth-gate.test.tsx
Expected: PASS.
Commit:
git add hooks components/chat components/ui/textarea.tsx components/auth/auth-gate.tsx
git commit -m "feat: build responsive streaming chat"
Files:
app/globals.cssapp/layout.tsxnext.config.tsREADME.mdpublic/og.pngInterfaces:
Consumes: complete auth/chat UI.
Produces: finished responsive product, security headers, operational setup instructions, and social preview metadata.
Step 1: Add a failing security configuration assertion
Create next-config.test.ts and assert the exported headers include Content-Security-Policy, Referrer-Policy, X-Content-Type-Options, and X-Frame-Options for all routes. The CSP must include default-src 'self', object-src 'none', base-uri 'self', frame-ancestors 'none', and no third-party origins.
Run: npm test -- next-config.test.ts
Expected: FAIL because next.config.ts has no headers.
Replace starter metadata with Russian product metadata:
export const metadata: Metadata = {
title: "AI Control Center",
description: "Корпоративный интерфейс для работы с ИИ-моделями и агентами.",
};
Set <html lang="ru">. In globals.css, define the warm neutral app background, sidebar surface, readable message width, composer shadow, overlay animation, responsive breakpoints, reduced-motion behavior, and full-height body. Keep both light and dark token definitions coherent even though the first screen defaults to light.
Use next.config.ts headers() rather than a nonce proxy so static rendering remains available. Allow only self-hosted scripts, fonts, and network requests; permit Next/React development requirements conditionally ('unsafe-inline' and development-only 'unsafe-eval'). Add object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, and X-Frame-Options: DENY.
Create exactly one landscape public/og.png representing the finished AI Control Center: neutral ChatGPT-like application frame, sidebar, model selector, message composer, Russian title AI Control Center, and no provider logos. Inspect the image once; retry only if text is unusable. Add Open Graph and X metadata only after the asset passes inspection.
Document npm install, .env.local variable names, the test login, AI_GATEWAY_MOCK=true, the exact Gateway request/stream contract, and npm run dev, npm test, npm run lint, npm run build. Explicitly warn that JWT_SECRET, AUTH_PASSWORD, and gateway keys must not be committed.
Run individually and fix only actual failures:
npm test
npm run lint
npm run build
Expected: all commands exit 0 with no failing tests or TypeScript/build errors.
Start npm run dev in a retained session. Send a login request, call /api/auth/me and /api/chat with the returned Bearer token, and confirm the mock chat response streams as plain text. Repeat /api/auth/me without the header and confirm 401. Stop the server after verification. Browser clicking and screenshots are omitted because they were not requested; component tests cover the corresponding interactions.
git add app components hooks lib next.config.ts next-config.test.ts public/og.png README.md
git commit -m "feat: complete corporate AI chat experience"