# Chat Attachments 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:** Let authenticated users upload, attach, view and send files to the AI Gateway through private S3-compatible storage. **Architecture:** The browser uploads a single file to an authenticated Next.js Node route, which validates and stores it in a private S3 bucket. PostgreSQL stores attachment metadata and message ownership. Sending a chat message links pending attachment IDs to the user message; the server generates short-lived signed S3 URLs for the existing Gateway request. **Tech Stack:** Next.js 16 Route Handlers, React 19, PostgreSQL 16, `pg`, MinIO, AWS SDK v3 S3 client and presigner, Vitest, Testing Library. ## Global Constraints - Supported types are PNG, JPEG, WebP, PDF, TXT, Markdown and DOCX. - Limit each file to 20 MiB and each message to 10 files. - Buckets are private; object keys are `users//attachments/`. - Upload and object-view routes require the existing `Authorization: Bearer ` scheme. - Route handlers that parse `FormData` export `runtime = "nodejs"` and enforce limits in application code. - Only attachment metadata and temporary signed URLs reach the AI Gateway; application JWTs never do. - Pending uploads expire after 24 hours; completed message attachments do not expire. --- ### Task 1: Object-store configuration and attachment schema **Files:** - Modify: `package.json`, `package-lock.json`, `.env.example`, `docker-compose.yml`, `Dockerfile`, `docker-entrypoint.sh` - Create: `db/migrations/002_chat_attachments.sql`, `lib/storage/s3.ts`, `lib/storage/s3.test.ts` **Interfaces:** - Produces `getObjectStore(): S3Client`, `ensureAttachmentBucket(): Promise`, `putAttachmentObject(input): Promise`, `getAttachmentDownloadUrl(key): Promise`, `deleteAttachmentObject(key): Promise`. - Migration creates `chat_attachments(id, user_id, message_id, original_name, content_type, byte_size, object_key, state, expires_at, created_at)`. - [ ] **Step 1: Write the failing object-store test** ```ts it("uses a private deterministic object key and signed read URL", async () => { await putAttachmentObject({ key: "users/user-1/attachments/attachment-1", body: new Uint8Array([1]), contentType: "text/plain", }); expect(send).toHaveBeenCalledWith(expect.objectContaining({ input: expect.objectContaining({ Key: "users/user-1/attachments/attachment-1" }) })); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `npm test -- lib/storage/s3.test.ts` Expected: FAIL because `lib/storage/s3.ts` does not exist. - [ ] **Step 3: Install and configure S3 dependencies and MinIO** ```bash npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner ``` Add MinIO service `minio/minio`, ports `9000:9000` and `9001:9001`, a private bucket initializer, and app environment values `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE=true`. Add `node scripts/ensure-bucket.mjs` to the entrypoint after migration/seed. - [ ] **Step 4: Add migration and the minimal S3 adapter** ```sql CREATE TABLE chat_attachments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, message_id uuid REFERENCES chat_messages(id) ON DELETE CASCADE, original_name text NOT NULL, content_type text NOT NULL, byte_size bigint NOT NULL CHECK (byte_size > 0), object_key text NOT NULL UNIQUE, state text NOT NULL CHECK (state IN ('pending', 'attached')), expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX chat_attachments_user_state_idx ON chat_attachments (user_id, state, expires_at); ``` Use `S3Client`, `PutObjectCommand`, `DeleteObjectCommand`, `HeadBucketCommand`, `CreateBucketCommand` and `getSignedUrl`. Set `ACL` nowhere: the bucket remains private. - [ ] **Step 5: Run the focused test and Compose validation** Run: `npm test -- lib/storage/s3.test.ts && docker compose config` Expected: PASS; Compose has `db`, `minio`, bucket initializer and `app` dependencies. - [ ] **Step 6: Commit** ```bash git add package.json package-lock.json .env.example docker-compose.yml Dockerfile docker-entrypoint.sh db/migrations/002_chat_attachments.sql lib/storage/s3.ts lib/storage/s3.test.ts scripts/ensure-bucket.mjs git commit -m "feat: add private S3 attachment storage" ``` ### Task 2: Attachment metadata repository and validation **Files:** - Create: `lib/chat/attachments.ts`, `lib/chat/attachments.test.ts`, `lib/db/attachments.ts`, `lib/db/attachments.test.ts` - Modify: `lib/chat/types.ts`, `lib/db/conversations.ts` **Interfaces:** - Produces `Attachment`, `PendingAttachment`, `validateUpload(file: File): ValidatedAttachment`, `createPendingAttachment`, `listOwnedAttachments`, `attachPendingAttachments`, `getOwnedAttachment`, `deletePendingAttachment`, `listMessageAttachments`. - `ChatMessage` gains `attachments: Attachment[]`. - [ ] **Step 1: Write failing validation and repository tests** ```ts it("rejects an executable renamed as PDF", async () => { const file = new File([new Uint8Array([0x4d, 0x5a])], "invoice.pdf", { type: "application/pdf" }); await expect(validateUpload(file)).rejects.toMatchObject({ status: 400 }); }); it("links only the caller's unexpired pending attachment IDs", async () => { await expect(attachPendingAttachments(db, "user-1", "message-1", ["attachment-1"])).resolves.toHaveLength(1); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- lib/chat/attachments.test.ts lib/db/attachments.test.ts` Expected: FAIL because attachment modules and types do not exist. - [ ] **Step 3: Implement exact validation rules** ```ts export const MAX_ATTACHMENTS_PER_MESSAGE = 10; export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; export const allowedContentTypes = new Set([ "image/png", "image/jpeg", "image/webp", "application/pdf", "text/plain", "text/markdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ]); ``` Normalize filename to its final path component and 255 UTF-8-safe characters. Check PNG, JPEG, WebP and PDF signatures; require `PK` ZIP signature for DOCX; reject other binary formats. Validate size before reading bytes. - [ ] **Step 4: Implement SQL repository methods** ```ts export async function attachPendingAttachments( database: Queryable, userId: string, messageId: string, ids: string[], ): Promise { // UPDATE ... SET message_id=$3, state='attached', expires_at='infinity' // WHERE id = ANY($1) AND user_id=$2 AND state='pending' AND expires_at > now() // RETURNING metadata } ``` Require the returned row count to equal the requested distinct ID count or throw a 404-style ownership error. Extend conversation hydration with one batched attachment query rather than one query per message. - [ ] **Step 5: Run tests** Run: `npm test -- lib/chat/attachments.test.ts lib/db/attachments.test.ts lib/db/conversations.test.ts` Expected: PASS; invalid signatures and cross-user IDs are rejected. - [ ] **Step 6: Commit** ```bash git add lib/chat/types.ts lib/chat/attachments.ts lib/chat/attachments.test.ts lib/db/attachments.ts lib/db/attachments.test.ts lib/db/conversations.ts git commit -m "feat: persist validated chat attachments" ``` ### Task 3: Secure upload, view and cleanup routes **Files:** - Create: `app/api/attachments/route.ts`, `app/api/attachments/[id]/route.ts`, `app/api/attachments/routes.test.ts`, `scripts/cleanup-pending-attachments.mjs` - Modify: `docker-entrypoint.sh`, `scripts/migrate.mjs` **Interfaces:** - `POST /api/attachments` returns `{ attachment: PendingAttachment }`. - `GET /api/attachments/:id` returns a 302 signed object URL only for the owner. - `DELETE /api/attachments/:id` removes a pending upload and its S3 object. - [ ] **Step 1: Write failing upload route tests** ```ts it("stores an authenticated valid text upload as pending metadata", async () => { const form = new FormData(); form.set("file", new File(["brief"], "brief.txt", { type: "text/plain" })); const response = await POST(authenticatedMultipartRequest(form)); expect(response.status).toBe(201); expect(putAttachmentObject).toHaveBeenCalledOnce(); }); it("rejects an attachment ID owned by another user", async () => { expect((await GET(otherUsersAttachmentRequest)).status).toBe(404); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- app/api/attachments/routes.test.ts` Expected: FAIL because routes do not exist. - [ ] **Step 3: Implement `POST /api/attachments` with explicit Node runtime** ```ts export const runtime = "nodejs"; const formData = await request.formData(); const file = formData.get("file"); if (!(file instanceof File)) return badRequest(); const validated = await validateUpload(file); const attachment = await createPendingAttachment(pool, user.id, validated); await putAttachmentObject({ key: attachment.objectKey, body: await file.arrayBuffer(), contentType: attachment.contentType }); ``` If S3 fails, remove the pending row. If the DB write after S3 fails, delete the object. Do not use browser-provided S3 keys. - [ ] **Step 4: Implement owner-protected read/delete and cleanup** ```sql SELECT id, object_key FROM chat_attachments WHERE id = $1 AND user_id = $2; ``` `GET` creates a 10-minute signed URL and returns `Response.redirect(url, 302)`. Cleanup selects expired pending rows, deletes each object and then its row; run it once at container start and expose `npm run attachments:cleanup`. - [ ] **Step 5: Run route and cleanup tests** Run: `npm test -- app/api/attachments/routes.test.ts && npm run attachments:cleanup -- --dry-run` Expected: PASS; invalid payloads return 400, no token returns 401, foreign IDs return 404 and S3 failures return 503. - [ ] **Step 6: Commit** ```bash git add app/api/attachments lib/chat/attachments.ts lib/db/attachments.ts scripts/cleanup-pending-attachments.mjs docker-entrypoint.sh package.json git commit -m "feat: add authenticated attachment upload routes" ``` ### Task 4: Link attachments to chat sends and AI Gateway payloads **Files:** - Modify: `app/api/chat/route.ts`, `app/api/chat/route.test.ts`, `lib/chat/validation.ts`, `lib/chat/validation.test.ts`, `lib/chat/types.ts`, `lib/chat/gateway.ts`, `lib/chat/gateway.test.ts` **Interfaces:** - `PersistentChatRequest` gains `attachmentIds?: string[]`. - `ChatRequest` gains `attachments?: GatewayAttachment[]`. - `streamGatewayResponse(request, signal)` receives generated signed attachment URLs. - [ ] **Step 1: Write failing chat and Gateway tests** ```ts it("links validated attachment IDs to the newly created user message", async () => { const response = await POST(authenticatedRequest({ model: "qwen", content: "Summarize", attachmentIds: ["attachment-1"] })); expect(attachPendingAttachments).toHaveBeenCalledWith(expect.anything(), "user-1", "user-message-1", ["attachment-1"]); expect(response.status).toBe(200); }); it("forwards signed attachment descriptors but never the application JWT", async () => { await streamGatewayResponse({ model: "chatgpt", messages: [], attachments: [gatewayAttachment] }, new AbortController().signal); expect(JSON.parse(String(fetchMock.mock.calls[0][1].body)).attachments[0].url).toMatch(/^https:/); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- app/api/chat/route.test.ts lib/chat/validation.test.ts lib/chat/gateway.test.ts` Expected: FAIL because `attachmentIds` and `attachments` are not accepted. - [ ] **Step 3: Implement request validation and atomic attachment linking** ```ts if (attachmentIds.length > MAX_ATTACHMENTS_PER_MESSAGE || new Set(attachmentIds).size !== attachmentIds.length) { throw new ChatValidationError(); } const userMessageId = await createMessage(...); const attachments = await attachPendingAttachments(database, user.id, userMessageId, attachmentIds); const gatewayAttachments = await Promise.all(attachments.map(toGatewayAttachment)); ``` Wrap message creation and linking in a transaction. On an ownership/expiry failure roll back the message and return 404. Continue supporting text-only turns unchanged; require at least one of content or attachment IDs. - [ ] **Step 4: Extend mock and HTTP Gateway payload** ```ts type GatewayAttachment = Pick & { url: string }; type ChatRequest = { model: ModelId; messages: MessageInput[]; attachments?: GatewayAttachment[] }; ``` The mock response includes a comma-separated attachment filename list when attachments are provided. The HTTP adapter sends this `attachments` field inside its existing JSON body. - [ ] **Step 5: Run focused tests** Run: `npm test -- app/api/chat/route.test.ts lib/chat/validation.test.ts lib/chat/gateway.test.ts` Expected: PASS; attachment metadata is linked, expired/foreign IDs fail safely and the outbound request contains signed URLs only. - [ ] **Step 6: Commit** ```bash git add app/api/chat/route.ts app/api/chat/route.test.ts lib/chat/validation.ts lib/chat/validation.test.ts lib/chat/types.ts lib/chat/gateway.ts lib/chat/gateway.test.ts git commit -m "feat: send chat attachments through AI Gateway" ``` ### Task 5: Attachment composer and message-history UI **Files:** - Create: `components/chat/chat-attachments.tsx`, `components/chat/chat-attachments.test.tsx` - Modify: `components/chat/chat-composer.tsx`, `components/chat/chat-composer.test.tsx`, `components/chat/chat-message-list.tsx`, `hooks/use-chat.ts`, `hooks/use-chat.test.tsx`, `components/chat/chat-app.tsx` **Interfaces:** - `useChat` exposes `uploadAttachments(files: FileList | File[]): Promise` and `pendingAttachments: PendingAttachment[]`. - `ChatComposer.onSend(content, attachmentIds)` accepts selected uploaded attachment IDs. - [ ] **Step 1: Write failing UI tests** ```tsx it("uploads a selected file, renders its card and sends its ID", async () => { await user.upload(screen.getByLabelText("Прикрепить файлы"), new File(["brief"], "brief.txt", { type: "text/plain" })); expect(await screen.findByText("brief.txt")).toBeInTheDocument(); await user.click(screen.getByLabelText("Отправить сообщение")); expect(JSON.parse(String(chatRequest.body)).attachmentIds).toEqual(["attachment-1"]); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- components/chat/chat-composer.test.tsx hooks/use-chat.test.tsx` Expected: FAIL because no attach control or attachment ID payload exists. - [ ] **Step 3: Add accessible selection, drop and upload states** ```tsx ``` Use `FormData` per file with `authFetch("/api/attachments", { method: "POST", body: formData })`. Render pending, uploaded and failed cards; `DELETE /api/attachments/:id` removes an uploaded pending card. Support drag enter/leave/drop on the composer surface. Do not set `Content-Type` manually for `FormData`. - [ ] **Step 4: Render attached files in chat history** ```tsx {message.attachments.map((attachment) => ( {attachment.contentType.startsWith("image/") ? {attachment.name} : } {attachment.name} ))} ``` Allow sending a file-only message. Keep keyboard behavior: Enter sends only when no upload is pending/failed. - [ ] **Step 5: Run UI tests** Run: `npm test -- components/chat/chat-composer.test.tsx components/chat/chat-attachments.test.tsx hooks/use-chat.test.tsx` Expected: PASS; selection, removal, drag/drop, upload error and chat send behavior are covered. - [ ] **Step 6: Commit** ```bash git add components/chat/chat-attachments.tsx components/chat/chat-attachments.test.tsx components/chat/chat-composer.tsx components/chat/chat-composer.test.tsx components/chat/chat-message-list.tsx hooks/use-chat.ts hooks/use-chat.test.tsx components/chat/chat-app.tsx git commit -m "feat: add chat attachment composer" ``` ### Task 6: Documentation, smoke scenario and final verification **Files:** - Modify: `README.md`, `.env.example`, `scripts/smoke-db.sh`, `docker-compose.yml` - Create: `scripts/attachment-smoke.txt` - [ ] **Step 1: Write the smoke extension expectation** ```sh attachment_id=$(curl --fail --silent --header "Authorization: Bearer $token" --form "file=@scripts/attachment-smoke.txt;type=text/plain" http://localhost:3000/api/attachments | node -e '...') curl --fail --silent --header "Authorization: Bearer $token" --header 'Content-Type: application/json' --data "{\"model\":\"chatgpt\",\"content\":\"Read attachment\",\"attachmentIds\":[\"$attachment_id\"]}" http://localhost:3000/api/chat ``` - [ ] **Step 2: Run the smoke script to verify failure before the end-to-end implementation** Run: `./scripts/smoke-db.sh` Expected: FAIL before Task 3 because `/api/attachments` does not exist. - [ ] **Step 3: Document local and production configuration** Add the MinIO endpoint/console ports, required S3 environment variables, supported file limits, cleanup command and a production note to use a private managed S3 bucket plus the same reverse-proxy request limit. - [ ] **Step 4: Extend the smoke script and add sample file** Ensure the script waits for app readiness, logs in, uploads `scripts/attachment-smoke.txt`, sends it by `attachmentIds`, and verifies a 200 stream response. It must use `trap` to shut down Compose after finishing. - [ ] **Step 5: Run all verification** Run: `npm test && npm run lint && npx tsc --noEmit && npm run build && docker compose config && ./scripts/smoke-db.sh` Expected: all unit/component/route tests, production build, Compose configuration and attachment smoke scenario pass. - [ ] **Step 6: Commit** ```bash git add README.md .env.example docker-compose.yml scripts/smoke-db.sh scripts/attachment-smoke.txt git commit -m "docs: document chat attachment deployment" ```