---
title: "StorageAdapter"
description: "The attachment-storage contract — private at rest, signed short-lived reads, and user-namespaced paths."
---

`StorageAdapter` is the attachment-storage contract. The package ships a Supabase
implementation (`createSupabaseStorage`) and a hosted implementation; any S3, R2,
or other backend that satisfies this interface is a valid drop-in.

```ts
import type {
  StorageAdapter,
  StorageAdapterFactory,
  UploadInput,
  UploadResult,
} from '@mordn/chat-widget/server';
```

## The three rules

These three rules are the security model. Every implementation must uphold all
three — they are load-bearing, not advisory.

> **1. Private at rest**
>
> Stored objects must **not** be publicly readable. No public bucket, no permanent
> public URL. The only way to read an object is through a signed, expiring URL this
> adapter mints. Making the bucket public reopens a data-leak hole the package
> otherwise closes.

> **2. Signed, short-lived reads**
>
> `upload` and `resign` return URLs that expire. A leaked URL stops working. Expiry
> is the adapter's choice but should be short (minutes to a few hours) — long enough
> for the model to fetch the file mid-turn and for the user to see the thumbnail.
> History rehydration re-signs on demand via `resign`, so short expiry costs nothing
> in UX.

> **3. User-namespaced, unguessable paths**
>
> The adapter is bound to one verified user (like `ChatStore`). It derives every
> storage path from its bound `userId` plus a random segment — callers never supply
> the full path, so an upload cannot be aimed into another user's namespace. Paths
> include a random segment so they are unguessable even if a bucket were accidentally
> made listable.

## Factory

```ts
type StorageAdapterFactory = (userId: string) => StorageAdapter;
```

The router calls the factory with the **server-verified** `userId` after
authentication — never from anything client-supplied. The `userId` must come from
the server session; passing a client-controlled value would reintroduce the IDOR
the bound-adapter design prevents.

## Properties


- **`userId`** · `readonly string`

  The user this adapter is bound to. Set at construction; the adapter uses it to
  namespace every path it writes and to validate paths before signing or deleting.


## Methods


- **`upload`** · `(input: UploadInput) => Promise<UploadResult>`

  Store a file and return a signed URL plus its durable `storagePath`.

  The adapter derives the path from its bound `userId` plus a random segment and a
  sanitised filename — callers cannot inject an absolute or foreign path. It writes
  the bytes to private storage and mints a short-lived signed URL. Throws on storage
  failure so the router returns a clean 5xx rather than handing the client a
  half-uploaded attachment.

- **`resign`** · `(storagePath: string) => Promise<string \| null>`

  Mint a fresh signed URL for an already-stored object given the `storagePath`
  returned by a prior `upload`. Called by the router when loading old conversations
  so the user always gets a live URL.

  The adapter must verify the path belongs to its bound user's namespace before
  signing. Returns `null` if it does not, or if the object is missing. A `null`
  result means **render a broken/expired thumbnail** — it must never cause the
  whole conversation history to be thrown away. Do not throw.

- **`remove`** · `(storagePath: string) => Promise<void>`

  Permanently delete a stored object by `storagePath`. Called when a conversation
  is deleted. The adapter must verify the path is in the bound user's namespace
  before deleting. Idempotent — no-op (does not throw) if the object is already
  gone.


## Supporting types

### UploadInput


- **`data`** · `ArrayBuffer \| Uint8Array` · required

  Raw bytes of the file.

- **`filename`** · `string` · required

  Original filename used for display and to derive a safe path segment.

- **`mediaType`** · `string` · required

  MIME type the client claimed. The router validates it against the
  `UploadPolicy.allowedMediaTypes` allow-list before calling `upload`; the adapter
  may re-check defensively.

- **`size`** · `number` · required

  Size in bytes. The router enforces the size cap before calling `upload`.

- **`conversationId`** · `string`

  The conversation this attachment belongs to. Used as a path segment for
  organisation only — not as an ownership signal (ownership comes from the adapter's
  bound user). Falls back to an "unfiled" segment when omitted.


### UploadResult


- **`storagePath`** · `string`

  Durable, opaque pointer into the storage backend. Persisted on the message part
  and used for all future `resign` and `remove` calls. Stable across reads.

- **`url`** · `string`

  Freshly-signed, expiring URL for immediate use (model fetch and attachment
  preview). Ephemeral — do not store it as a permanent reference.

- **`filename`** · `string`

  Echoed back for convenience.

- **`mediaType`** · `string`

  Echoed back for convenience.

- **`size`** · `number`

  Echoed back for convenience.


## Building your own adapter

Implement `StorageAdapter` and return it from a `StorageAdapterFactory`. The three
rules above are the security contract — uphold all of them or the attachment
isolation guarantee breaks. See the
[Supabase backend guide](/docs/backends/supabase) for a reference implementation
and the [attachments guide](/docs/guides/attachments) for the full upload flow.
