Files
CLEMA has two deliberately separate file models:
- Stored attachment — uploaded to the configured object store, attached to a durable message and available from history on every device.
- Direct transfer — an online-only, one-shot relay whose bytes are never stored. History keeps only a record of the offer and how it ended.
Stored Attachment Flow
Sender Server / object store Recipient
│── file.create ───────────────>│ │
│<── file_id, part_size, parts ─│ │
│── PUT /files/{id}/parts/1 ───>│ │
│── file.get (resume) ─────────>│ │
│── file.complete ─────────────>│ │
│── message.send {file_id} ────>│── message.created ────────────────>│
│ │── media processing │
│<── file.updated (if attached) ┼── file.updated ───────────────────>│
│ │<── GET /files/{file_id} ───────────│
The upload and message are separate operations. file.complete publishes the
object as ready, then message.send atomically claims it. Until that final
command succeeds, the file is absent from history.
Stored bytes are kept once, however many attachments show them: a file and its forwards share them, and so do two uploads of the same bytes once the second is found to be the first's. They are kept while any attachment shows them, and deleted a day after the last one goes.
Create or Resume Upload
{
"id": "50",
"type": "file.create",
"data": {
"operation_id": "0195d1a0-upload-photo",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg"
}
}
{
"id": "50",
"type": "file.create",
"ok": true,
"data": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg",
"status": "pending",
"part_size": 8388608,
"parts": [],
"expires_in": 86400
}
}
operation_id is idempotent per owner. Retrying file.create with the same ID,
name and size returns the original file_id and its current uploaded parts, or
the file as it is once complete; reusing it with another name or size returns
conflict. The content type is not compared: it is the client's guess until
file.complete replaces it with what the bytes are, and a retry that lost the
answer to file.complete still sends its guess. To refresh an already known
upload explicitly, use:
{"id":"51","type":"file.get","data":{"file_id":"4dbf74d3bb384ca0a86b29479a2a45ea"}}
The response has the same shape. Each item in parts is
{"part":1,"etag":"...","size":2048000}. Only the owner can inspect an
unattached upload.
Upload Part
PUT /files/{file_id}/parts/{part_number}
Authorization: Bearer <access_token>
Content-Type: application/octet-stream
Content-Length: <exact part size>
[binary part]
Parts are numbered from 1. Every non-final part must equal part_size; the
final part is the exact remainder. part_size is normally the configured
default and grows automatically for very large files to stay within the S3
limit of 10,000 parts. A successful response is:
HTTP/1.1 200 OK
ETag: "<etag>"
Content-Type: application/json
{"etag":"<etag>"}
Uploading the same part number again replaces it, which makes an interrupted part retry safe.
Complete Upload
{
"id": "52",
"type": "file.complete",
"data": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea"
}
}
{
"id": "52",
"type": "file.complete",
"ok": true,
"data": {
"file": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg",
"available": true,
"media": {
"kind": "image",
"state": "pending"
}
}
}
}
The server reads the uploaded part list from object storage, then verifies part
numbers, sizes, ETags and the final object size before marking it ready. It also
sniffs the stored bytes and replaces the client-declared content_type with the
detected value. The client does not echo the potentially large part list over
WebSocket. Repeating completion of an already ready or attached file is safe
and returns its current metadata.
Once stored, the bytes are read in the background for their SHA-256, which
the message's snapshots then give as the file's hash, in lowercase hex, as
does any later file.updated; it is empty until then, and for files over
2 GiB, which are not read for it.
Bytes found to be stored already are kept once: the file shows the stored copy
from then on, and its media is that copy's. Media inspection follows hashing.
For detected images, video and audio, media.state is initially pending (or
processing). A single bounded background worker inspects the actual streams,
refines ambiguous containers such as MP4, WebM and Ogg to audio or video,
and extracts dimensions and duration_ms; images and video also receive a JPEG
preview whose longest edge is at most 960 pixels. A HEIC, HEIF or AVIF photo
is measured and previewed as the whole picture, upright, rather than as the
tiles its container cuts it into. Processing input is bounded
at 2 GiB and generated previews at 8 MiB. The original object is immutable and
remains downloadable if
inspection fails or a file exceeds the processing bound. In that case the final
state is failed and no derivative is required. Media processing is optional at
deployment time; without a processor detected media starts as failed. The
bundled image uses FFmpeg/FFprobe, and libheif for HEIF images.
The public descriptor is:
| Field | Description |
|---|---|
kind |
image, video or audio, derived from bytes |
state |
pending, processing, ready or failed |
width, height |
Original visual dimensions when known |
duration_ms |
Audio/video duration in milliseconds when known |
preview |
Ready derivative descriptor: variant, type, dimensions and size |
No new message kinds are introduced: media remains a file message and clients
that do not understand the optional descriptor keep their normal file UI.
Media Updated
If processing finishes after the file has been attached, every conversation
member receives file.updated, an event of the conversation stream; a member
who hid the message is only told its file_id:
{
"type": "file.updated",
"conversation_id": "direct:12345:67890",
"seq": "44",
"data": {
"file": {
"file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
"name": "photo.jpg",
"size": 2048000,
"content_type": "image/jpeg",
"available": true,
"media": {
"kind": "image",
"state": "ready",
"width": 1920,
"height": 1080,
"preview": {
"variant": "preview",
"content_type": "image/jpeg",
"width": 960,
"height": 540,
"size": 84217
}
}
}
}
}
Clients replace the payload of the message carrying that file_id. If
processing wins the race with message.send, no update is necessary and the
created message already contains the final descriptor.
Abort Upload
{"id":"53","type":"file.abort","data":{"file_id":"4dbf74d3bb384ca0a86b29479a2a45ea"}}
This removes a pending multipart upload or a completed-but-unattached object. An attached file cannot be aborted; delete its message for everyone instead.
Download Stored File
GET /files/{file_id}
Authorization: Bearer <access_token>
Range: bytes=0-1048575
The sender and recipient may download while the message remains visible to
them. An unrelated or locally hidden user receives 404, avoiding resource
disclosure. Responses include Content-Disposition, Content-Length,
Accept-Ranges: bytes, ETag when available and 206 Partial Content with
Content-Range for a range request.
An unsatisfiable range returns 416 / invalid_format.
Inline Media and Preview Access
Browser media elements cannot attach a Bearer header to their follow-up Range requests. A visible conversation member obtains a short-lived scoped ticket:
POST /files/{file_id}/access
Authorization: Bearer <access_token>
{
"content_url": "/files/{file_id}/content?ticket=...",
"preview_url": "/files/{file_id}/variants/preview?ticket=...",
"expires_at": "2026-08-05T12:30:00Z"
}
preview_url is omitted until a preview is ready and for audio. URLs are
server-relative, expire after 30 minutes and are bound to exactly one active
authentication session, user and file_id. Each GET rechecks both the session
and current message visibility, so logout, remote session revocation, deletion,
history hiding or conversation removal immediately removes access. The content
endpoint serves only byte-detected passive media with inline content disposition,
X-Content-Type-Options: nosniff and Range support. The variant endpoint
inherits the same authorization and lifecycle. A regular Bearer header is also
accepted by both GET endpoints for non-browser clients.
The ticket is a narrow playback capability, not a persistent download URL. A client requests another ticket after expiry or a media load failure. Generic files and active markup never receive inline access and continue through the attachment download endpoint above.
Abandoned Uploads and Deletion
Each successful part refreshes upload activity. Pending uploads and ready files
that were never attached are removed after MANDARIN_FILE_UPLOAD_TTL (24 hours
by default). Sebastian independently cleans its multipart staging with the
same activity-based TTL. A global message deletion marks the attachment for
background object deletion. A one-sided hide retains bytes for the other
participant; once both participants hide the message or clear that history,
the attachment and every derivative are collected as well.
Direct Transfer
A direct transfer requires the recipient to have a client that handles
transfers within reach: a connection, passive or not,
whose connection.hello declared transfer.created, or a device registered
for pushes whose client declared it when it connected
last. Otherwise transfer.create returns invalid_state. The sender must be
at their client: a passive connection cannot offer a file. The relay is
memory-only and one-shot; what history keeps of it is its
record.
Create Offer
{
"id": "60",
"type": "transfer.create",
"data": {
"conversation_id": "direct:12345:67890",
"name": "temporary.zip",
"size": 7340032,
"content_type": "application/zip"
}
}
A transfer goes between the two participants of a direct conversation; another
kind of conversation returns invalid_state. The sender response contains
transfer_id, msg_id (the transfer's record), conversation_id, from,
to, file metadata, token and expires_in. The recipient
receives the same metadata without the token, after the record's
message.created:
{
"type": "transfer.created",
"data": {
"transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
"msg_id": "12420",
"conversation_id": "direct:12345:67890",
"from": 67890,
"to": 12345,
"name": "temporary.zip",
"size": 7340032,
"content_type": "application/zip",
"expires_in": 300
}
}
Accept, Decline or Cancel
The recipient accepts with:
{"id":"61","type":"transfer.accept","data":{"transfer_id":"66ca36b6a2904e59a55d96c2155a9cf1"}}
The response contains {"token":"..."}. The sender receives
transfer.accepted with transfer_id and user_id, the recipient's. A
passive connection may accept too: the person answered a notification.
The recipient may instead send transfer.decline; either party may send
transfer.cancel. Both use:
{
"id": "62",
"type": "transfer.cancel",
"data": {
"transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
"reason": "optional text"
}
}
The other party receives transfer.declined or transfer.cancelled with
transfer_id, user_id (who declined or cancelled) and optional reason,
and so do the actor's other
clients, any of which may hold the offer or the file. A transfer that ends
without either party's word is reported to both as transfer.cancelled with a
reason and no user_id: expired, failed for a relay that broke off,
unavailable for a block and withdrawn when its sender deleted its
record for everybody.
Direct HTTP Relay
After acceptance, the sender and recipient use the same capability token but must also authenticate in their respective roles:
POST /transfers/{token} GET /transfers/{token}
Authorization: Bearer <sender> Authorization: Bearer <recipient>
Content-Length: <offered size>
The server streams the POST body directly into the GET response and stores no
bytes. The declared size must match exactly. A second concurrent downloader is
rejected with 409 conflict. Missing, expired and unauthorized sessions use
the structured HTTP errors described above. Offers expire after 5 minutes;
accepted transfers have a 10-minute idle timeout.
Transfer Record
An offer is also written into the direct conversation as a message of kind
transfer, from the sender, with the next message.created. Nobody sends one
— message.send refuses the kind — and it cannot be edited or forwarded; it
can be replied to, reacted to, pinned and deleted like any message. For the
recipient it is unread like any message and wakes those of their devices whose
client handles transfers, with the file name as its preview.
{
"msg_id": "12420",
"conversation_id": "direct:12345:67890",
"from": 67890,
"kind": "transfer",
"payload": {
"transfer_id": "66ca36b6a2904e59a55d96c2155a9cf1",
"name": "temporary.zip",
"size": 7340032,
"content_type": "application/zip",
"state": "offered"
},
"time": "2024-01-15T14:40:00Z",
"revision": 1,
"reactions": []
}
state says how far the transfer got. Each move is a message.edited with the
next revision and no edited_at, and a record only moves up the table — from
offered to accepted to one of the states that end it — so word of a
transfer that comes late, such as an acceptance recorded after the bytes went
across, changes nothing:
| State | Meaning |
|---|---|
offered |
Waiting for the recipient |
accepted |
Accepted; the bytes are under way |
completed |
The relay carried the whole file |
declined |
The recipient declined |
cancelled |
A party cancelled, a block ended it, or its sender withdrew it |
expired |
Not answered in time, or accepted and never carried |
failed |
Broke off on the way, or its taker fell behind |
While the record says offered, any of the recipient's clients may accept or
decline by its transfer_id, and either party may cancel while it says
offered or accepted. Deleting the record for everybody withdraws an offer
still open. Transfers live in the server's memory: one still open when the
server stops ends at its next start, an offer as expired and an accepted
transfer as failed, and a record that says a transfer is open that nothing
relays is ended the same way when a party acts on it, which answers
transfer_not_found. A record deleted for everybody keeps no state anybody sees.
transfer.create answers with the transfer rather than the record, so the
record's message.created reaches every client of both parties, the one that
offered included; the record's later moves likewise reach all of them.
File Security
- Upload, download and access-ticket issuance require a valid bearer token. Inline media GETs may instead use a 30-minute HMAC-authenticated ticket scoped to one persistent session, user and file; session validity and current message visibility are still checked on every GET.
- Stored object keys and multipart upload IDs never appear in the protocol;
clients receive only opaque
file_idvalues. - Client-declared MIME types are not trusted for inline rendering. Completion sniffs stored bytes; active markup and unknown content remain attachments.
- A direct
tokenis random and single-use, but possession alone is insufficient: sender/recipient role authorization is still enforced. - File names are metadata only, path separators and control characters are
rejected, and downloads use
Content-Disposition: attachmentplusX-Content-Type-Options: nosniff.