You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Let users drag-drop / paste a file into the composer and have it round-trip through the chat:
File uploads to a Cloudflare R2 bucket.
The assistant-ui runtime receives a CompleteAttachment whose content is the message parts the model will see (image URL / file URL / extracted text, depending on type).
We persist an attachments row keyed to (user_id, thread_id, message_id) so we can render the attachment in the thread later, dedupe per-thread, and (later) feed it into the knowledge base.
This issue is just upload + storage + per-message reference. Knowledge-base ingestion is a separate follow-up — see Out of scope.
Goals
Wire assistant-ui's AttachmentAdapter (no custom file-picker UI; the <Composer /> already supports attachments when an adapter is provided).
Store files in Cloudflare R2, not on the Next.js server (zero disk, zero proxying of file bytes through us).
Persist the public/signed URL + metadata in Postgres so the file is recoverable across reloads, deletable, and consumable by future tools (KB ingestion, search, etc.).
Per-user isolation: file lookups MUST scope by user_id; cross-user access returns 404 (matches the existing data-isolation pattern — see docs/AUTH.md).
Self-host-friendly: env-driven config, no Cloudflare-specific business logic outside the S3 client wrapper. A future drop-in to Backblaze B2 / MinIO / AWS S3 should be a one-line endpoint change.
Frontend wiring — assistant-ui AttachmentAdapter
The adapter contract (verified against @assistant-ui/core@0.2.20/dist/adapters/attachment.d.ts installed in this repo):
PendingAttachment has status: { type: "running", progress: number } so the <Composer /> can render a progress bar. CompleteAttachment has content: ThreadUserMessagePart[] — these parts are what the model sees. For images we send { type: "image", image: "<public-url>" }; for PDFs/docs we send { type: "file", data: "<public-url>", mimeType: "...", filename: "..." } (the content-part field is data, not url — per the assistant-ui content-part shape; verified against the installed @assistant-ui/core types).
@assistant-ui/react-langgraph's useLangGraphRuntime({ ... }) accepts attachments?: AttachmentAdapter under adapters.attachments (dist/types.d.ts:228-229). Existing built-ins (SimpleImageAttachmentAdapter, SimpleTextAttachmentAdapter, CompositeAttachmentAdapter) are too narrow — they inline the bytes into the prompt via base64, which is exactly what we DON'T want for KB / large files. Verified: there is no ServerUploadAdapter exported in @assistant-ui/core@0.2.20 — the docs reference one but it's illustrative code, not a class. We must write our own.
assistant-ui — attachment UI rendering: https://www.assistant-ui.com/docs/ui/attachment (composer's AttachmentPrimitive.Root / .Name / .Remove give the chip for free; thread-side rendering is out of scope).
Don't hand-roll an upload handler that streams the file through Next.js. The community-standard pattern (also documented in Vercel's "Using R2 with Next.js") is:
Presigned PUT URL — the client asks our API for a short-lived URL scoped to a specific R2 object key.
Direct upload — the client PUTs the file bytes straight to R2. Next.js never sees the bytes.
Confirm — client calls our API again to say "upload finished" with the key; we verify the object exists and persist a row in attachments.
For the S3 client, the de-facto choice is the AWS SDK v3:
@aws-sdk/client-s3 — list / head / delete objects, validate size + content-type after upload.
@aws-sdk/s3-request-presigner — generate the presigned PUT URL the client uses.
Both work against R2's S3-compatible endpoint (https://<accountid>.r2.cloudflarestorage.com) without any R2-specific code. No custom upload library needed — this is the well-trodden path and avoids the uploadthing / Uppy / etc. abstractions until we actually need them.
Step 5 lives entirely in the browser; the LLM receives URLs and (in the image case) can fetch them. We'll rely on the same rule #10 redaction as observability spans (no token / secret leakage in spans), so the URLs R2 returns are safe to put in the prompt.
DB schema
New attachments table (Drizzle, alongside the existing lib/threads/schema.ts / lib/auth/schema.ts modules). Reasonable starter shape — adjust on review:
exportconstattachment=pgTable("attachment",{id: text("id").primaryKey(),// nanoid, 12 charsuserId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"}),threadId: text("thread_id").references(()=>thread.id,{onDelete: "set null"}),messageId: text("message_id"),// nullable; set after the chat run commitsr2Key: text("r2_key").notNull().unique(),// e.g. "u/<user-id>/<nanoid>-<safe-filename>"name: text("name").notNull(),// original filenamecontentType: text("content_type").notNull(),size: integer("size").notNull(),// bytes; reject over a cappublicUrl: text("public_url").notNull(),// what's emitted to the modelcreatedAt: timestamp("created_at").defaultNow().notNull(),deletedAt: timestamp("deleted_at"),// soft delete; cron reaps after N days},(table)=>[index("attachment_userId_idx").on(table.userId),index("attachment_threadId_idx").on(table.threadId)],);
ON DELETE CASCADE on user_id (matches existing pattern, see docs/DB.md). ON DELETE SET NULL on thread_id so deleting a thread doesn't take the underlying R2 object with it (the cron job will).
API surface (new routes under app/api/attachments/)
Heads the R2 object: re-reads ContentType from R2, asserts it matches the server-chosen value, asserts ContentLength matches the declared size; inserts row.
This is the single biggest risk of an attachment feature and is worth designing in from day one. Five vectors to cover:
Spoofed Content-Type — the browser's File.type is client-supplied; a user can upload evil.svg and call it image/png. If R2 then serves it as image/svg+xml and the browser renders it inline, the SVG's <script> runs in our origin. Mitigation:
Server-side allow-list of MIME types (the env var R2_ALLOWED_CONTENT_TYPES defaults above already excludes SVG / HTML / XML). Reject anything else at presign time.
Server-chosen content type, re-verified after upload: in confirm, HeadObject the R2 object and read ContentType back; the value MUST equal the ContentType we signed into the presigned PUT URL (which the server picked from the allow-list). The browser cannot influence this because we never read its claim — we trust the server's signed choice, re-verified from R2's stored metadata. No libmagic — the server-decided allow-list makes sniffing redundant.
R2 upload via presigned PUT must be told the content type from the server side, not the client: pass ContentType in the PutObjectCommand when generating the presigned URL. The browser can lie but the signed URL enforces what it signed.
Dangerous formats that LOOK like images — SVG, HTML, XHTML, XML, EPUB. Even with the allow-list, defense-in-depth:
Set Content-Disposition: attachment on the R2 bucket policy for the bucket (or per-object metadata) so the browser downloads instead of rendering. Especially important for any format the browser interprets as HTML/JS.
If a future use case legitimately needs SVG upload, serve it from a separate origin (e.g. attachments-cdn.<PUBLIC_DOMAIN> with no cookies, no app session) so an XSS in the SVG can't reach the app's authenticated state.
Filename rendering — the original file.name is user-supplied and may contain <script>alert(1)</script>.pdf. Anywhere we render a filename in the UI (attachment chips in <Thread />, file picker chrome, error messages), we MUST escape it. The assistant-ui <Thread /> uses React's default JSX escaping, so plain {name} is safe; the danger is if we ever pass filenames through dangerouslySetInnerHTML, markdown rendering without escaping, or URL-construction without encoding.
Filenames in URLs: always encodeURIComponent. Filenames in attributes: always escape quotes.
Filenames as text: plain JSX {name} is fine; if we use a Markdown / rich-text renderer, ensure it doesn't allow raw HTML.
Public URL handling — the publicUrl we hand to the model and to the frontend is server-controlled (we generate the URL from R2_PUBLIC_BASE_URL + our own key, never from user input). Still:
The assistant-ui renderer will fetch the URL on the user's behalf. Confirm CSP img-src / connect-src allowlists the R2 public domain; nothing else should be reachable from the chat runtime.
Don't pass user-controlled strings into the URL path. The key is server-constructed (<userId>/<nanoid>-<safe-filename>); the safe-filename step strips path separators and control chars.
CSP — review next.config.* (or wherever the security headers live) and add:
Markdown / link rendering — if the model's reply contains a link to the attachment, the renderer must not let javascript: URLs through. assistant-ui's default markdown renderer is OK, but if we add a custom link handler we have to re-check it.
Add to the TDD plan below: at minimum an XSS-filename escaping test, a server-side MIME-validation test, and a CSP-header test on the upload route.
Environment
New env vars (extend .env.example + docs/AUTH.md table):
The adapter implements AttachmentAdapter from dist/adapters/attachment.d.ts. Wrap it in a class so future @assistant-ui/core API bumps touch one import.
Out of scope (separate issues)
Knowledge-base ingestion (the user already flagged this in the request). Add a tool that, given a message_id, looks up its attachments and (for PDFs / text files) extracts chunks into the existing backend/store.ts Postgres-backed store. Follow-up issue, separate lifecycle.
Image OCR / vision model for non-image attachments.
Attachment rendering in the assistant-ui <Thread /> (displaying thumbnails / file chips alongside messages). The composer-side chip is provided by AttachmentPrimitive.Root / .Name for free; the thread-side MessagePrimitive.Attachments + AttachmentComponents map is a separate polish item.
Server-side virus scanning before persisting.
Resumable / chunked uploads for files > R2's presigned PUT URL limits.
Acceptance criteria
User can drag-drop / paste a file into <Composer /> and the upload progress shows in the UI.
On send, the model receives image / file message parts and can reference the URL.
Reloading the thread still shows the attached files (row in attachments is keyed to thread_id).
DELETE /api/attachments/[id] removes both the DB row and the R2 object.
Cross-user GET returns 404 (rule from docs/AUTH.md).
Upload size + content-type caps are enforced server-side; oversized / disallowed uploads 4xx with a clear error surfaced to <Composer />.
Server-side Content-Type validation: the value stored in R2 and the value in the attachments row MATCH one of the allowed MIME types (no SVG / HTML / XHTML / XML, no client-supplied types). Verified by re-reading ContentType from HeadObject in tests/api/attachments/confirm.test.ts.
Filename rendering is XSS-safe anywhere the UI displays a filename (assistant-ui <Thread />, error toasts, MCP tool responses). React JSX default escaping is sufficient; covered by a render test with <script>-bearing filenames.
Content-Disposition: attachment set on the R2 bucket (or per-object) so the browser downloads instead of inline-rendering anything that could carry scripts.
CSP updated: img-src / connect-src allowlist the R2 public domain; object-src 'none'; no unsafe-inline.
No new secrets in the client bundle — R2_* env vars are server-only; only R2_PUBLIC_BASE_URL may be exposed if needed for direct rendering (decide based on public-bucket vs signed-URL strategy).
pnpm lint, pnpm typecheck, pnpm test all green; new tests under tests/api/attachments/ cover presign / confirm / GET / DELETE.
Red: tests/api/attachments/presign.test.ts — mock the S3 client; assert the presigner is called with the right Bucket / Key / ContentType, and that the response shape is { url, key, publicUrl }. Mock withAuth to throw a 401 path test.
Red: tests/api/attachments/confirm.test.ts — mock s3.send(new HeadObjectCommand(...)) to return the expected ContentLength / ContentType; assert the DB row is inserted with the right fields. Negative case: HeadObject returns mismatched ContentType or ContentLength → 400, no row.
Green: confirm route handler.
Red: tests/frontend/assistant/r2-adapter.test.tsx — mock fetch for presign + confirm. Two critical assertions:
add() is implemented as an async * generator (per the AsyncGenerator<PendingAttachment, void> overload in @assistant-ui/core@0.2.20dist/adapters/attachment.d.ts:5-12) and must explicitly yield { type: "running", progress: 1 } as its final state before returning — per assistant-ui docs: "Yield final progress so the 100% state reaches the composer." Without this yield the composer's progress UI stalls below 100%.
send() returns a CompleteAttachment whose content[0] is { type: "image", image: "<public-url>" } for images, or { type: "file", data: "<public-url>", mimeType, filename } for non-images — note data, not url.
Green: the R2AttachmentAdapter class.
Refactor with green tests: extract a single getS3Client() helper, add the env-cap validation.
XSS coverage (RED → GREEN):
tests/api/attachments/confirm.test.ts — extend: send ContentType: "image/svg+xml" from the client side; assert the route rejects with 400 (server-side allow-list wins, not the browser's claim).
tests/api/attachments/presign.test.ts — extend: request a presigned URL with ContentType: "text/html"; assert the route rejects with 400.
tests/frontend/assistant/attachment-render.test.tsx — render the attachment chip with name = "<img src=x onerror=alert(1)>.png"; assert the literal string appears as text and is NOT parsed into a DOM node.
tests/api/headers.test.ts (or whichever test covers response headers) — assert CSP includes img-src 'self' <R2_PUBLIC_BASE_URL> and object-src 'none'.
Notes for the implementer
R2 key convention: <userId>/<nanoid>-<safe-filename> — keeps objects namespaced for easy debugging and lets us prefix-list for cleanup.
Public URL vs signed URL: start with a public bucket + custom domain (simplest). If security review demands it, switch to presigned GET URLs with a longer TTL and refresh-on-render.
Avoid streaming through Next.js: even for 1 MiB files. The whole point of presigned URLs is that the browser talks to R2 directly — putting a Next.js route in the middle kills throughput and eats serverless function duration.
No built-in server-upload adapter in @assistant-ui/core@0.2.20 — verified dist/adapters/index.d.ts only exports SimpleImageAttachmentAdapter, SimpleTextAttachmentAdapter, CompositeAttachmentAdapter. We must write our own R2AttachmentAdapter. (assistant-ui docs reference a ServerUploadAdapter — it's illustrative code, not an exported class.)
Rule [Bug]: Memory tab Socials row missing email per linked provider #10 alignment: the attachment tool surface (when KB ingestion lands) must follow the same lazy-registration pattern — addAttachmentTool: StructuredTool | null = process.env.R2_BUCKET ? tool(...) : null. Out of scope for this issue but worth pre-empting.
XSS first-class: any line of code that takes a File.name / File.type and shoves it into a URL, attribute, or text node must include the safety check at the same time. Don't add a separate "hardening pass" later.
Feature: chat attachments backed by Cloudflare R2
Let users drag-drop / paste a file into the composer and have it round-trip through the chat:
CompleteAttachmentwhosecontentis the message parts the model will see (image URL / file URL / extracted text, depending on type).attachmentsrow keyed to(user_id, thread_id, message_id)so we can render the attachment in the thread later, dedupe per-thread, and (later) feed it into the knowledge base.This issue is just upload + storage + per-message reference. Knowledge-base ingestion is a separate follow-up — see Out of scope.
Goals
AttachmentAdapter(no custom file-picker UI; the<Composer />already supports attachments when an adapter is provided).user_id; cross-user access returns 404 (matches the existing data-isolation pattern — seedocs/AUTH.md).Frontend wiring — assistant-ui
AttachmentAdapterThe adapter contract (verified against
@assistant-ui/core@0.2.20/dist/adapters/attachment.d.tsinstalled in this repo):PendingAttachmenthasstatus: { type: "running", progress: number }so the<Composer />can render a progress bar.CompleteAttachmenthascontent: ThreadUserMessagePart[]— these parts are what the model sees. For images we send{ type: "image", image: "<public-url>" }; for PDFs/docs we send{ type: "file", data: "<public-url>", mimeType: "...", filename: "..." }(the content-part field isdata, noturl— per the assistant-ui content-part shape; verified against the installed@assistant-ui/coretypes).@assistant-ui/react-langgraph'suseLangGraphRuntime({ ... })acceptsattachments?: AttachmentAdapterunderadapters.attachments(dist/types.d.ts:228-229). Existing built-ins (SimpleImageAttachmentAdapter,SimpleTextAttachmentAdapter,CompositeAttachmentAdapter) are too narrow — they inline the bytes into the prompt via base64, which is exactly what we DON'T want for KB / large files. Verified: there is noServerUploadAdapterexported in@assistant-ui/core@0.2.20— the docs reference one but it's illustrative code, not a class. We must write our own.Docs to read before implementing (in order):
AttachmentAdapterreference: https://www.assistant-ui.com/docs/api-reference/runtimes/AttachmentAdapter.AttachmentPrimitive.Root/.Name/.Removegive the chip for free; thread-side rendering is out of scope).Backend — community-standard R2 wiring
Don't hand-roll an upload handler that streams the file through Next.js. The community-standard pattern (also documented in Vercel's "Using R2 with Next.js") is:
PUTs the file bytes straight to R2. Next.js never sees the bytes.attachments.For the S3 client, the de-facto choice is the AWS SDK v3:
@aws-sdk/client-s3— list / head / delete objects, validate size + content-type after upload.@aws-sdk/s3-request-presigner— generate the presigned PUT URL the client uses.Both work against R2's S3-compatible endpoint (
https://<accountid>.r2.cloudflarestorage.com) without any R2-specific code. No custom upload library needed — this is the well-trodden path and avoids the uploadthing / Uppy / etc. abstractions until we actually need them.Reference implementations to crib from:
@aws-sdk/client-s3against R2.Proposed architecture
Step 5 lives entirely in the browser; the LLM receives URLs and (in the image case) can fetch them. We'll rely on the same rule #10 redaction as observability spans (no token / secret leakage in spans), so the URLs R2 returns are safe to put in the prompt.
DB schema
New
attachmentstable (Drizzle, alongside the existinglib/threads/schema.ts/lib/auth/schema.tsmodules). Reasonable starter shape — adjust on review:ON DELETE CASCADEonuser_id(matches existing pattern, seedocs/DB.md).ON DELETE SET NULLonthread_idso deleting a thread doesn't take the underlying R2 object with it (the cron job will).API surface (new routes under
app/api/attachments/)/api/attachments/presign{ fileName, contentType, size }→{ url, key, publicUrl }ALLOWED_CONTENT_TYPES./api/attachments/confirm{ key, contentType, name, size }→{ id, publicUrl }ContentTypefrom R2, asserts it matches the server-chosen value, assertsContentLengthmatches the declaredsize; inserts row./api/attachments/[id]{ id, name, contentType, size, publicUrl, createdAt }docs/AUTH.md)./api/attachments/[id]Rule #9: every route wrapped in
withAuth. No exception (login is for/api/auth/*only).Update
docs/APIS.mdin the same commit (rule #1).Security — XSS / injection
This is the single biggest risk of an attachment feature and is worth designing in from day one. Five vectors to cover:
Spoofed
Content-Type— the browser'sFile.typeis client-supplied; a user can uploadevil.svgand call itimage/png. If R2 then serves it asimage/svg+xmland the browser renders it inline, the SVG's<script>runs in our origin. Mitigation:R2_ALLOWED_CONTENT_TYPESdefaults above already excludes SVG / HTML / XML). Reject anything else atpresigntime.confirm,HeadObjectthe R2 object and readContentTypeback; the value MUST equal the ContentType we signed into the presigned PUT URL (which the server picked from the allow-list). The browser cannot influence this because we never read its claim — we trust the server's signed choice, re-verified from R2's stored metadata. Nolibmagic— the server-decided allow-list makes sniffing redundant.ContentTypein thePutObjectCommandwhen generating the presigned URL. The browser can lie but the signed URL enforces what it signed.Dangerous formats that LOOK like images — SVG, HTML, XHTML, XML, EPUB. Even with the allow-list, defense-in-depth:
Content-Disposition: attachmenton the R2 bucket policy for the bucket (or per-object metadata) so the browser downloads instead of rendering. Especially important for any format the browser interprets as HTML/JS.attachments-cdn.<PUBLIC_DOMAIN>with no cookies, no app session) so an XSS in the SVG can't reach the app's authenticated state.Filename rendering — the original
file.nameis user-supplied and may contain<script>alert(1)</script>.pdf. Anywhere we render a filename in the UI (attachment chips in<Thread />, file picker chrome, error messages), we MUST escape it. The assistant-ui<Thread />uses React's default JSX escaping, so plain{name}is safe; the danger is if we ever pass filenames throughdangerouslySetInnerHTML, markdown rendering without escaping, or URL-construction without encoding.encodeURIComponent. Filenames in attributes: always escape quotes.{name}is fine; if we use a Markdown / rich-text renderer, ensure it doesn't allow raw HTML.Public URL handling — the
publicUrlwe hand to the model and to the frontend is server-controlled (we generate the URL fromR2_PUBLIC_BASE_URL+ our own key, never from user input). Still:img-src/connect-srcallowlists the R2 public domain; nothing else should be reachable from the chat runtime.<userId>/<nanoid>-<safe-filename>); thesafe-filenamestep strips path separators and control chars.CSP — review
next.config.*(or wherever the security headers live) and add:Content-Security-Policy: img-src 'self' <R2_PUBLIC_BASE_URL>; connect-src 'self' <R2_PUBLIC_BASE_URL>; object-src 'none'; frame-ancestors 'none'.unsafe-inlinescripts anywhere.Markdown / link rendering — if the model's reply contains a link to the attachment, the renderer must not let
javascript:URLs through. assistant-ui's default markdown renderer is OK, but if we add a custom link handler we have to re-check it.Add to the TDD plan below: at minimum an XSS-filename escaping test, a server-side MIME-validation test, and a CSP-header test on the upload route.
Environment
New env vars (extend
.env.example+docs/AUTH.mdtable):R2_ACCOUNT_IDR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_BUCKETlanggraph-app-attachments)R2_PUBLIC_BASE_URLhttps://pub-<bucket>.r2.devR2_MAX_BYTES10485760(10 MiB)R2_ALLOWED_CONTENT_TYPESimage/png,image/jpeg,image/webp,application/pdfCD.ymland the Dockerfile stay unchanged — env-only.Composer's adapter wiring
In
app/assistant.tsx(or whereveruseLangGraphRuntime({ ... })is called):The adapter implements
AttachmentAdapterfromdist/adapters/attachment.d.ts. Wrap it in a class so future@assistant-ui/coreAPI bumps touch one import.Out of scope (separate issues)
message_id, looks up its attachments and (for PDFs / text files) extracts chunks into the existingbackend/store.tsPostgres-backed store. Follow-up issue, separate lifecycle.<Thread />(displaying thumbnails / file chips alongside messages). The composer-side chip is provided byAttachmentPrimitive.Root/.Namefor free; the thread-sideMessagePrimitive.Attachments+AttachmentComponentsmap is a separate polish item.Acceptance criteria
<Composer />and the upload progress shows in the UI.attachmentsis keyed tothread_id).DELETE /api/attachments/[id]removes both the DB row and the R2 object.GETreturns 404 (rule fromdocs/AUTH.md).<Composer />.Content-Typevalidation: the value stored in R2 and the value in theattachmentsrow MATCH one of the allowed MIME types (no SVG / HTML / XHTML / XML, no client-supplied types). Verified by re-readingContentTypefromHeadObjectintests/api/attachments/confirm.test.ts.<Thread />, error toasts, MCP tool responses). React JSX default escaping is sufficient; covered by a render test with<script>-bearing filenames.Content-Disposition: attachmentset on the R2 bucket (or per-object) so the browser downloads instead of inline-rendering anything that could carry scripts.img-src/connect-srcallowlist the R2 public domain;object-src 'none'; nounsafe-inline.R2_*env vars are server-only; onlyR2_PUBLIC_BASE_URLmay be exposed if needed for direct rendering (decide based on public-bucket vs signed-URL strategy).pnpm lint,pnpm typecheck,pnpm testall green; new tests undertests/api/attachments/cover presign / confirm / GET / DELETE.docs/APIS.mdupdated (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).docs/AUTH.mdenv table updated with the newR2_*vars.TDD plan (rule #2)
tests/api/attachments/presign.test.ts— mock the S3 client; assert the presigner is called with the rightBucket/Key/ContentType, and that the response shape is{ url, key, publicUrl }. MockwithAuthto throw a 401 path test.app/api/attachments/presign/route.tscallinggetSignedUrl(s3, new PutObjectCommand(...), { expiresIn: 300 }).tests/api/attachments/confirm.test.ts— mocks3.send(new HeadObjectCommand(...))to return the expectedContentLength/ContentType; assert the DB row is inserted with the right fields. Negative case:HeadObjectreturns mismatchedContentTypeorContentLength→ 400, no row.tests/frontend/assistant/r2-adapter.test.tsx— mockfetchfor presign + confirm. Two critical assertions:add()is implemented as anasync *generator (per theAsyncGenerator<PendingAttachment, void>overload in@assistant-ui/core@0.2.20dist/adapters/attachment.d.ts:5-12) and must explicitly yield{ type: "running", progress: 1 }as its final state before returning — per assistant-ui docs: "Yield final progress so the 100% state reaches the composer." Without this yield the composer's progress UI stalls below 100%.send()returns aCompleteAttachmentwhosecontent[0]is{ type: "image", image: "<public-url>" }for images, or{ type: "file", data: "<public-url>", mimeType, filename }for non-images — notedata, noturl.R2AttachmentAdapterclass.getS3Client()helper, add the env-cap validation.tests/api/attachments/confirm.test.ts— extend: sendContentType: "image/svg+xml"from the client side; assert the route rejects with 400 (server-side allow-list wins, not the browser's claim).tests/api/attachments/presign.test.ts— extend: request a presigned URL withContentType: "text/html"; assert the route rejects with 400.tests/frontend/assistant/attachment-render.test.tsx— render the attachment chip withname = "<img src=x onerror=alert(1)>.png"; assert the literal string appears as text and is NOT parsed into a DOM node.tests/api/headers.test.ts(or whichever test covers response headers) — assert CSP includesimg-src 'self' <R2_PUBLIC_BASE_URL>andobject-src 'none'.Notes for the implementer
<userId>/<nanoid>-<safe-filename>— keeps objects namespaced for easy debugging and lets us prefix-list for cleanup.@assistant-ui/core@0.2.20— verifieddist/adapters/index.d.tsonly exportsSimpleImageAttachmentAdapter,SimpleTextAttachmentAdapter,CompositeAttachmentAdapter. We must write our ownR2AttachmentAdapter. (assistant-ui docs reference aServerUploadAdapter— it's illustrative code, not an exported class.)addAttachmentTool: StructuredTool | null = process.env.R2_BUCKET ? tool(...) : null. Out of scope for this issue but worth pre-empting.ACL; object is public via bucket policy, not per-object").File.name/File.typeand shoves it into a URL, attribute, or text node must include the safety check at the same time. Don't add a separate "hardening pass" later.Related
@assistant-ui/react-langgraphuseLangGraphRuntime({ adapters.attachments })—dist/types.d.ts:228-229.@assistant-ui/coreAttachmentAdapter—dist/adapters/attachment.d.ts:5-12.AttachmentPrimitive(Root,Name,Remove) — composer chip primitives for free.lib/auth/with-auth.ts— every new route goes through this (rule [Feat]: Email verification has no result page between verify and login redirect #9).lib/threads/schema.ts— the existing Drizzle module pattern to mirror.docs/AUTH.md§ Data isolation — cross-user 404 rule.docs/DB.md— schema conventions; newattachmenttable follows the same shape.docs/APIS.md— new routes must be added in the same commit (rule feat(001): stage 1 — user auth (Better Auth + email verification + thread ownership) #1).