Attachments

Uploads require both a client feature flag and a server StorageAdapterFactory. Client checks improve UX; server policy is authoritative.

Client

code
<ChatWidget
  apiBase="/api/chat"
  config={{
    schemaVersion: 1,
    runtime: { model: 'anthropic/claude-sonnet-4-5' },
    client: {
      features: {
        fileUpload: true,
        fileUploadAccept: 'image/*,application/pdf',
        fileUploadMaxBytes: 10 * 1024 * 1024,
      },
    },
  }}
/>

fileUploadAccept uses browser accept syntax. fileUploadMaxBytes prevents an obviously oversized request but is not a security boundary.

Server

code
createChatHandler({
  getUserId: getChatUserId,
  model,
  store,
  storage: createSupabaseStorage(),
  upload: {
    allowedMediaTypes: [
      'image/png',
      'image/jpeg',
      'image/webp',
      'application/pdf',
    ],
    maxBytes: 10 * 1024 * 1024,
  },
});

allowedMediaTypes is exact-match; image/* is not a server wildcard. maxBytes defaults to 5 MiB and oversized files return 413.

If no storage adapter is configured, the upload route returns 503 with File upload is not configured. Hide the client upload affordance in that case.

Security invariants

  • Store objects in a private bucket.
  • Return short-lived signed URLs from upload and resign.
  • Derive paths from the bound verified user; callers cannot choose another user's namespace.
  • Return null from resign for inaccessible/missing objects.
  • Make removal idempotent.

The Supabase adapter uses <userId>/<conversationId>/<randomUUID>/<safeFilename> and verifies the bound user prefix for re-sign/removal. The service-role key must remain server-only.

History reload re-signs stored attachment paths. A missing object does not remove the message or fail the full history response.