aegida-console / docs / superpowers / plans / 2026-07-26-chat-attachments.md
2026-07-26-chat-attachments.md
Raw

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/<user-id>/attachments/<attachment-id>.
  • Upload and object-view routes require the existing Authorization: Bearer <JWT> 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<void>, putAttachmentObject(input): Promise<void>, getAttachmentDownloadUrl(key): Promise<string>, deleteAttachmentObject(key): Promise<void>.

  • 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

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
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
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
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

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
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
export async function attachPendingAttachments(
  database: Queryable, userId: string, messageId: string, ids: string[],
): Promise<Attachment[]> {
  // 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
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

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
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
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
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"

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

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
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
type GatewayAttachment = Pick<Attachment, "id" | "name" | "contentType" | "size"> & { 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
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<void> and pendingAttachments: PendingAttachment[].

  • ChatComposer.onSend(content, attachmentIds) accepts selected uploaded attachment IDs.

  • Step 1: Write failing UI tests

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
<input accept={ACCEPTED_FILE_TYPES} aria-label="Прикрепить файлы" multiple onChange={onFilesSelected} ref={inputRef} type="file" className="sr-only" />
<Button aria-label="Прикрепить файлы" onClick={() => inputRef.current?.click()} type="button"><Paperclip /></Button>

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
{message.attachments.map((attachment) => (
  <a href={`/api/attachments/${attachment.id}`} key={attachment.id} target="_blank" rel="noreferrer">
    {attachment.contentType.startsWith("image/") ? <img alt={attachment.name} src={`/api/attachments/${attachment.id}`} /> : <FileText />}
    <span>{attachment.name}</span>
  </a>
))}

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
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

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
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"