Attachments
Files linked to records in a tenant, uploaded through signed URLs to a private bucket and served only after a malware scan.
The attachments block links files to records in a tenant. Each file gets
a row in attachments and an object in a private Storage bucket at
{organization_id}/attachments/{id}. The bucket's policies accept an
upload only when a pending record of the caller expects it, and serve a
file only after your scanner marks it clean.
better-supabase sql add attachments # adds tenant and access as well| Column | Holds |
|---|---|
subject_type, subject_id | The record the file belongs to, or both null |
bucket, object_path | Where the object lives; the path is generated from the id |
name, mime_type, size | The file name, and the type and size Storage stored |
status, scan_detail | pending, clean, infected or failed, and what the scan found |
uploaded_by, uploaded_at | The uploader, and when confirm saw the object |
| Permission | Lets a member | Default roles |
|---|---|---|
attachments.read | list attachments and download clean files | member, admin |
attachments.upload | upload files | member, admin |
attachments.manage | read any file and delete other members' files | admin (attachments.*) |
The uploader can always read and delete their own file.
Options
export default defineConfig({
sql: {
modules: {
attachments: {
options: {
bucket: "attachments",
maxSize: 25 * 1024 * 1024,
allowedMimeTypes: ["image/*", "application/pdf"],
requireScan: true,
},
},
},
},
});The module creates the bucket as private with maxSize (default 50 MiB) as
its file size limit and allowedMimeTypes as its allowed types; an existing
bucket keeps its settings. The table checks the same limits. With
requireScan: false, confirm marks a file clean right away, and files
nobody scanned are served too (an infected one never is).
Subjects and buckets
subjects maps each subject type to its table, as for
comments: uploading and reading a file then
also need the subject row to be readable through the subject table's own
policies (and its permission), and an unlisted type is refused. A subject
can keep its files in its own bucket with its own MIME types, so an app with a
bucket per feature keeps them:
options: {
bucket: "attachments",
path: "{organization_id}/{subject_type}/{subject_id}/{id}",
subjects: {
chat_message: { table: "chat_messages", bucket: "chat", allowedMimeTypes: ["image/*", "application/pdf"] },
expense: { table: "expenses", bucket: "receipts", allowedMimeTypes: ["image/*"], cascade: true },
},
},The module creates every listed bucket that doesn't exist yet and adds its
storage policies for each, next to any policies the bucket already has. With
subjects, reading an object also needs its subject: the storage read policy
checks that the caller sees the attachment row through its own read policy
(attachment_object_visible), so a member who can't see the subject, or who
isn't in the tenant, can't download its files, uploader and
attachments.manage included.
cascade: true deletes a subject's attachment rows with the subject row;
remove the objects with attachments.remove or a storage cleanup job.
A subject without a tenant, such as a user's own notes, takes tenant: false:
its files have no organization_id (pass organizationId: null to upload),
their path starts with -, and the subject table's own policies alone decide
who reads and uploads them, without tenant permissions. Other files still need
a tenant.
subjects: {
note: { table: "notes", tenant: false },
},path sets the object path, from {organization_id} (required first),
{id} (required), {subject_type} and {subject_id} (a file without a
subject gets -). It defaults to {organization_id}/attachments/{id}.
Changing it later rewrites the column on Postgres 17; on older versions,
recreate the column in a migration.
Upload and download
Uploading takes three steps: create the record and a signed upload URL on the server, upload the file from the browser, then confirm.
"use server";
import {
createAttachments,
rpcTransport,
} from "better-supabase/blocks/attachments";
export async function startUpload(projectId: string, file: FileMeta) {
const supabase = await createServerClient();
const attachments = createAttachments({
transport: rpcTransport(supabase),
storage: supabase.storage,
});
return attachments
.upload({
organizationId,
subjectType: "project",
subjectId: projectId,
name: file.name,
mimeType: file.type,
size: file.size,
})
.orThrow();
}The browser uploads with the token,
supabase.storage.from(bucket).uploadToSignedUrl(path, token, file), or a
PUT to signedUrl. Then attachments.confirm(id) checks that the object
exists, takes its size and type from Storage and writes
attachment.uploaded to the outbox. confirm returns
not_found with the hint ATTACHMENT_NOT_UPLOADED until the object exists.
upload takes metadata, a JSON object kept with the record in the
metadata column (a caption, where the file came from, a page count), and
every attachment returns it as metadata ({} without any).
download(id) returns a signed URL (300 seconds by default, downloadTtl
changes it; { as: "plan.pdf" } sets the download name). Until the scan
passes it returns invalid_request with the hint ATTACHMENT_NOT_SCANNED.
list(organizationId, { type, id }) lists a record's files oldest first,
and remove(id) deletes the object and then the row.
Files the server makes or fetches (an export, a generated PDF, an import
from a URL) go through put(attachment, file), which creates the record,
uploads the bytes with the storage client and confirms it in one call.
read(id) returns the bytes as a Blob for server-side processing, behind
the same scan gate as download (ATTACHMENT_NOT_SCANNED until the scan
passes):
const report = await attachments
.put(
{
organizationId,
subjectType: "project",
subjectId,
name: "report.pdf",
mimeType: "application/pdf",
size: pdf.byteLength,
},
pdf,
)
.orThrow();
const { file } = await attachments.read(attachmentId).orThrow();
const text = await extractText(await file.arrayBuffer());Pass the caller's Supabase client so the table and bucket policies apply;
createAttachments never needs the service role. A service-role client works
too, for jobs that act for no user.
Scanning
createAttachmentScanner runs your scanner (ClamAV, a scanning API) on each
uploaded file with a service-role client, and records the verdict with
set_attachment_status, which only the service role can call.
import {
createAttachmentScanner,
sqlTransport,
} from "better-supabase/blocks/attachments";
const scanner = createAttachmentScanner({
transport: sqlTransport(postgres.admin),
storage: supabaseAdmin.storage,
scan: async (file) => {
const result = await clamav.scan(await file.arrayBuffer());
return result.infected
? { status: "infected", detail: result.signature }
: { status: "clean" };
},
});
await outbox.relay("attachment-scans", scanner.sink());scanner.sink() scans the file of each attachment.uploaded event, and
scanner.job is a jobs handler for an
{ attachmentId } payload when you prefer a queue. A file that is already
clean or infected is not scanned again, so replays are safe. When scan
throws, the file is marked failed with the error as scan_detail, the
result is an error with the hint ATTACHMENT_SCAN_FAILED, and the next
attempt scans it again. An event or job for an attachment that was deleted
before its scan is done: the sink skips it and the job completes, so one
deleted file never blocks the events behind it in the relay. Other errors,
such as a failed download or a throwing scan, still throw so the relay or
the queue retries.
Any bucket
The scan gate also works without the attachments table, for a file drive,
avatars or generated exports. scanned_objects holds one row per object,
object_clean(bucket, path) answers in any storage policy, and
createObjectScanner records verdicts with set_object_scan (service role):
create policy "drive files are clean" on storage.objects for select to authenticated
using (bucket_id = 'files' and better_supabase.object_clean(bucket_id, name) and ...);const objects = createObjectScanner({
transport: sqlTransport(postgres.admin),
storage: supabaseAdmin.storage,
scan: (file, { bucket, path }) => scanWithClamav(file),
});
await outbox.relay("object-scans", objects.sink());With scanBuckets: ["files"], a trigger on storage.objects gives each new
or replaced object in those buckets a pending row and writes an
object.uploaded event ({ bucket, path }), which objects.sink() scans;
objects.job takes the same payload from a queue. An object deleted from
storage before its scan (NoSuchKey) is skipped the same way; a missing
bucket or any other error still throws. Scanning an attachment also records
its object in scanned_objects, so both gates agree.
Events
With the outbox installed, the module writes these events with the tenant as
the partition key and attachments/<id> as the subject:
| Event | When | Data |
|---|---|---|
attachment.uploaded | confirm saw the object | attachmentId, subjectType, subjectId, uploadedBy, mimeType, size |
attachment.scanned | a scan result was recorded | attachmentId, status, uploadedBy |
object.uploaded | an object landed in a scanBuckets bucket (no tenant) | bucket, path |
Last updated on
Comments and activity
Threaded comments on any record in a tenant, mentions that notify, comment events in the outbox and an activity feed built from outbox events.
Data lifecycle
Data exports for a user or an organization as NDJSON files in Storage, and organization deletion with a grace period, a cancel and a purge job.