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.
users/<user-id>/attachments/<attachment-id>.Authorization: Bearer <JWT> scheme.FormData export runtime = "nodejs" and enforce limits in application code.Files:
package.json, package-lock.json, .env.example, docker-compose.yml, Dockerfile, docker-entrypoint.shdb/migrations/002_chat_attachments.sql, lib/storage/s3.ts, lib/storage/s3.test.tsInterfaces:
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" }) }));
});
Run: npm test -- lib/storage/s3.test.ts
Expected: FAIL because lib/storage/s3.ts does not exist.
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.
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.
Run: npm test -- lib/storage/s3.test.ts && docker compose config
Expected: PASS; Compose has db, minio, bucket initializer and app dependencies.
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"
Files:
lib/chat/attachments.ts, lib/chat/attachments.test.ts, lib/db/attachments.ts, lib/db/attachments.test.tslib/chat/types.ts, lib/db/conversations.tsInterfaces:
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);
});
Run: npm test -- lib/chat/attachments.test.ts lib/db/attachments.test.ts
Expected: FAIL because attachment modules and types do not exist.
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.
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.
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.
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"
Files:
app/api/attachments/route.ts, app/api/attachments/[id]/route.ts, app/api/attachments/routes.test.ts, scripts/cleanup-pending-attachments.mjsdocker-entrypoint.sh, scripts/migrate.mjsInterfaces:
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);
});
Run: npm test -- app/api/attachments/routes.test.ts
Expected: FAIL because routes do not exist.
POST /api/attachments with explicit Node runtimeexport 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.
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.
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.
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:
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.tsInterfaces:
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:/);
});
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.
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.
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.
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.
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"
Files:
components/chat/chat-attachments.tsx, components/chat/chat-attachments.test.tsxcomponents/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.tsxInterfaces:
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"]);
});
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.
<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.
{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.
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.
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"
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
Run: ./scripts/smoke-db.sh
Expected: FAIL before Task 3 because /api/attachments does not exist.
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.
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.
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.
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"