İçeriğe geç

API Reference

Bu içerik henüz dilinizde mevcut değil.

Base URL: https://localhost:38443 for the bundled local Compose edge, or your configured public SERVER_URL. Port 38080 redirects to HTTPS.

All authenticated endpoints require Authorization: Bearer <accessToken>.

Note: File content and metadata are end-to-end encrypted by the client. Account secrets use one canonical, suite-bearing envelope per value; the server validates only its public framing and never sees the plaintext or key.


Returns public server settings (e.g. registration enabled/disabled).

Auth: None

Response:

{
"registrationEnabled": true,
"chat": {
"enabled": true,
"protocolVersion": 1,
"suites": [1],
"maximumActiveDevices": 10,
"manifests": true,
"profiles": true,
"federation": true,
"sealedSender": true,
"mlsGroups": true
}
}

The chat block also advertises size/retention limits, the canonical federation server name when enabled, and the authenticated sealed-sender service-policy history. maximumActiveDevices is operator-configurable from 1 through the V1 hard cap of 10. A capability is advertised only when its complete local and federated path is enabled.


Create a new account with an encrypted key bundle. Rate-limited (10/hr/IP, RATE_LIMIT_REGISTER_PER_HOUR).

Auth: None

Request body:

{
"email": "user@example.com",
"username": "alice",
"loginKey": "<base64>",
"masterKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"recoveryKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"drivePrivateKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"publicKey": "<base64>",
"accountAuthorityPublicKey": "<base64 32 bytes>",
"accountAuthorityKeyId": "<lowercase SHA-256 hex>",
"accountIncarnationId": "<lowercase SHA-256 hex>",
"driveSigningPublicKey": "<base64 32 bytes>",
"accountProtectionSuite": 1,
"accountProtectionSalt": "<base64 16 bytes>",
"argonMemoryKib": 65536,
"argonIterations": 3,
"argonParallelism": 1,
"recoveryProof": "<base64>"
}

All key material is encrypted client-side before being sent. One parameterized Argon2id invocation derives an account-protection root; HKDF purpose subkeys produce the master-key KEK and loginKey. The server bcrypts only loginKey. recoveryProof is a distinct HKDF output bound to the canonical login email; the raw recovery entropy that opens recoveryKeyEnvelope is never sent. Each envelope authenticates its suite, purpose, canonical login email, nonce and exact ciphertext length as AEAD associated data. The three purposes cannot be substituted for one another or relocated to another account.

The complete account identity is fixed at registration: the account authority, incarnation, Drive X25519 key, and Drive Ed25519 share-signing key are persisted as one binding. A later account manifest must match all of them exactly; a self-signed first manifest cannot select a replacement authority.

Response: 201 Created

Errors: 403 if registration is disabled, 409 if the email or username is already taken.


Fetch the complete account-protection suite and parameters before submitting credentials. Rate-limited.

Auth: None Query: ?email=user@example.com

Response:

{
"accountProtectionSuite": 1,
"accountProtectionSalt": "<base64 16 bytes>",
"argonMemoryKib": 65536,
"argonIterations": 3,
"argonParallelism": 1
}

Exchange the Argon2id-derived login key for tokens. Rate-limited (10/min/IP, RATE_LIMIT_LOGIN_PER_MIN). On top of the per-IP limit, repeated failed password attempts for one email lock that account out: after 5 failures (LOGIN_LOCKOUT_THRESHOLD) further attempts return 429 for 15 minutes (LOGIN_LOCKOUT_MINUTES). The lockout applies to unknown emails too, so a 429 does not reveal whether the account exists.

Auth: None

Request body:

{
"email": "user@example.com",
"loginKey": "<base64>"
}

Response (no 2FA):

{
"accessToken": "<jwt>",
"userId": "<uuid>",
"username": "alice",
"masterKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"drivePrivateKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"publicKey": "<base64>",
"isAdmin": false,
"storageQuotaBytes": 5368709120,
"storageUsedBytes": 104857600
}

The refresh token is delivered via an HTTP-only cookie named refresh_token (scoped to Path=/api/auth/refresh) — it is not present in the JSON body.

Response (2FA enabled): 200 with {"requiresTotp": true, "preAuthToken": "<jwt>"} — proceed to /api/auth/login/2fa.

Response (first login, account created via ADMIN_ACCOUNT and not yet set up): 200 with {"requiresSetup": true, "setupToken": "<jwt>"} — proceed to /api/auth/complete-setup.


Complete login when 2FA is enabled. Locked after 5 failed attempts.

Auth: None (uses preAuthToken from the login response)

Request body:

{
"preAuthToken": "<jwt>",
"code": "123456"
}

Response: Same full token response as /api/auth/login (no 2FA branch).


Fetch the encrypted recovery key bundle so the client can decrypt the master key with the mnemonic-derived recovery key. Rate-limited (5/hr/IP). Returns deterministic fake data for non-existent emails to prevent user enumeration.

Auth: None Query: ?email=user@example.com

Response:

{
"recoveryKeyEnvelope": "<canonical base64 AccountEnvelopeV1>"
}

Recover an account using a mnemonic-derived recovery key. The client proves possession of the mnemonic with recoveryProof and submits a fresh key bundle derived from a new password. Rate-limited (5/hr/IP).

Auth: None

Request body:

{
"email": "user@example.com",
"recoveryProof": "<base64>",
"newLoginKey": "<base64>",
"newMasterKeyEnvelope": "<canonical base64 AccountEnvelopeV1>",
"newAccountProtectionSuite": 1,
"newAccountProtectionSalt": "<base64 16 bytes>",
"newArgonMemoryKib": 65536,
"newArgonIterations": 3,
"newArgonParallelism": 1
}

recoveryProof is the 32-byte HKDF-derived authorization proof. The server bcrypt-compares it to the verifier stored at registration; it never receives the recovery entropy used for decryption.


Exchange a refresh token for a new access token. The refresh token is normally read from the HTTP-only refresh_token cookie set at login; for clients that cannot rely on cookies, it may instead be passed in the JSON body.

Auth: None (the refresh token itself is the credential)

Request body (optional, only if no cookie is sent):

{
"refreshToken": "<jwt>"
}

Response:

{
"accessToken": "<jwt>"
}

Called after first login by accounts created via ADMIN_ACCOUNT that haven’t yet generated a recovery phrase. The client derives a full key bundle (mnemonic, master key, recovery entropy, account Drive keys and typed account envelopes) and submits it here.

Auth: Bearer setupToken (returned by /api/auth/login when requiresSetup is true)

Request body: Same shape as POST /api/auth/register (encrypted key bundle, salts, public key).

Response: issues an access token (JSON) and the refresh token (cookie) — the encrypted key bundle just submitted is not echoed back.

{
"accessToken": "<jwt>",
"userId": "<uuid>",
"username": "alice",
"isAdmin": false,
"storageQuotaBytes": 5368709120,
"storageUsedBytes": 0
}

Return the current user’s profile (public key + storage stats). The encrypted key bundle is not returned here — it is delivered as part of the /api/auth/login response.

Auth: Bearer JWT

Response:

{
"id": "<uuid>",
"email": "user@example.com",
"username": "alice",
"publicKey": "<base64>",
"totpEnabled": false,
"storageQuotaBytes": 5368709120,
"storageUsedBytes": 104857600,
"isAdmin": false
}

Generate a TOTP secret and return a QR code URI.

Auth: Bearer JWT

Response:

{
"secret": "BASE32SECRET",
"qrUri": "otpauth://totp/Kutup:user@example.com?secret=BASE32SECRET&issuer=Kutup"
}

The secret is stored as pending and only becomes active after POST /api/user/2fa/verify succeeds.


Confirm TOTP setup by providing the first valid code.

Auth: Bearer JWT

Request body:

{
"code": "123456"
}

Disable TOTP for the current user. Requires a valid TOTP code to prevent a stolen session from silently removing 2FA.

Auth: Bearer JWT

Request body:

{
"code": "123456"
}

Look up another local user’s registered Drive identity (used when sharing a collection).

Auth: Bearer JWT Param: :email — URL-encoded email address

Response:

{
"userId": "<uuid>",
"account": "bob@example.com",
"driveHpkePublicKey": "<canonical base64, 32 bytes>",
"accountIncarnationId": "<lowercase SHA-256 hex>",
"driveSigningPublicKey": "<canonical base64, 32 bytes>"
}

List all collections accessible to the current user (owned and shared).

Auth: Bearer JWT

Response: Array of collection objects. Owned and shared collections use the same authenticated record. An owned row contains ownerKeyEnvelope; a shared row contains namedShareEnvelope, isShared: true, and the owner’s registered identity fields required for independent client verification. Neither key envelope is returned in the other row type.

[
{
"id": "<uuid>",
"ownerUserId": "<uuid>",
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"namedShareEnvelope": "<NamedShareEnvelopeV1 base64>",
"keyEpoch": 1,
"nameRevision": 1,
"epochStatement": "<CollectionEpochStatementV1 base64>",
"epochStatementHash": "<lowercase SHA-256 hex>",
"ownerAccount": "alice@example.com",
"ownerIncarnationId": "<lowercase SHA-256 hex>",
"ownerDriveSigningPublicKey": "<canonical base64, 32 bytes>",
"ownerAuthorityPublicKey": "<canonical base64, 32 bytes>",
"parentCollectionId": null,
"color": "blue",
"canUpload": true,
"canDelete": false,
"uploadQuotaBytes": null,
"uploadUsedBytes": null,
"isShared": true
}
]

canUpload, canDelete, uploadQuotaBytes, uploadUsedBytes, isShared, and the owner identity fields are present only on shared collections (the owner has full rights implicitly). Clients verify the named-share signature, recipient incarnation, epoch statement, epoch hash, collection-key commitment and name envelope before displaying or using a shared collection.


Create a new collection.

Auth: Bearer JWT

Request body:

{
"id": "<client-generated canonical uuid>",
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"ownerKeyEnvelope": "<DriveEnvelopeV1 base64>",
"epochStatement": "<CollectionEpochStatementV1 base64>",
"parentCollectionId": null
}

The name envelope is bound to (collection ID, owner user ID, epoch 1, revision 1). The owner-key envelope binds the same identifiers with the collection-key purpose. The account authority signs the epoch-1 statement. Creation writes the collection and its first immutable epoch-history row in one database transaction.

Response: 201 Created

{
"id": "<uuid>"
}

Get a single owned or shared collection by ID. The response has the same owner-vs-recipient key-envelope separation as the list route.

Auth: Bearer JWT

Response:

{
"id": "<uuid>",
"ownerUserId": "<uuid>",
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"ownerKeyEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"nameRevision": 1,
"epochStatement": "<CollectionEpochStatementV1 base64>",
"epochStatementHash": "<lowercase SHA-256 hex>",
"parentCollectionId": null,
"color": "blue"
}

Rename a collection (client re-encrypts the name with the collection key).

Auth: Bearer JWT

Request body:

{
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"nameRevision": 2
}

The revision must be exactly one greater than the stored revision and the envelope must authenticate that exact revision, current epoch, collection and owner. Concurrent/stale renames fail instead of overwriting a newer name.

Response: 200 OK {"message": "updated"}.


Move a collection — with its whole subtree (sub-folders + files) — to the trash. The folder becomes a single trash entry; restore or purge it via the Trash endpoints. Items already in the trash keep their own entry and deletion time. While trashed, the subtree is invisible to every other endpoint (listings, downloads, shares, federation, collab) and its public share links go dark. Trashed items keep counting against quota until purged.

Auth: Bearer JWT (owner only)

Response: 204 No Content.


Set the display color of a folder.

Auth: Bearer JWT

Request body:

{
"color": "blue"
}

Response: 204 No Content.


Share a collection with another user on this server.

Auth: Bearer JWT

Request body:

{
"recipientUserId": "<uuid>",
"namedShareEnvelope": "<NamedShareEnvelopeV1 base64>",
"canUpload": false,
"canDelete": false,
"uploadQuotaBytes": null
}

namedShareEnvelope HPKE-encrypts the collection key to the recipient’s registered Drive key and signs the collection, epoch, sender/recipient accounts and both incarnation IDs with the sender’s registered Drive signing key. All recipients have read access; canUpload and canDelete are independent boolean grants. uploadQuotaBytes optionally caps uploads; omit (or null) for no per-share cap.

Response: 201 Created {"message": "shared"}. Re-sharing with the same recipient updates the existing grant (upsert).


Resolve a remote Drive user through authenticated v2 federation before sealing the collection key.

Auth: Bearer JWT Query: ?server=other.example.com (canonical DNS identity, not a URL)

Response:

{
"username": "bob",
"server": "other.example.com",
"account": "bob@other.example.com",
"driveHpkePublicKey": "<canonical base64, 32 bytes>",
"accountIncarnationId": "<lowercase SHA-256 hex>",
"driveSigningPublicKey": "<canonical base64, 32 bytes>",
"accountAuthorityPublicKey": "<canonical base64, 32 bytes>"
}

The response is accepted only after signed discovery, peer pin/rotation policy, admission, request authentication, and response authentication succeed.


POST /api/collections/:id/federated-shares

Section titled “POST /api/collections/:id/federated-shares”

Create a domain-bound share for a user on a remote Kutup instance.

Auth: Bearer JWT

Request body:

{
"recipientUsername": "bob",
"recipientServer": "other.example.com",
"namedShareEnvelope": "<NamedShareEnvelopeV1 base64>",
"canUpload": true,
"canDelete": false,
"uploadQuotaBytes": null
}

The named envelope is sealed to the exact bob@other.example.com identity returned by the lookup above. The origin validates the sender signature and all routing bindings before storing it. The destination repeats that validation against Bob’s current registered incarnation and verifies the owner-signed epoch statement before accepting the invite. The origin stores the canonical recipient domain and a SHA-256 capability verifier, not a remote URL or plaintext capability.

Response: 201 Created

{
"inviteUrl": "https://this.example.com/invite#server=this.example.com&capability=<base64url>"
}

The capability appears only in the fragment so browsers do not send it to the sharer’s web origin. It is shown once and cannot be recovered from the outgoing share row.


Upload an encrypted file to a collection. Multipart form.

Auth: Bearer JWT

Form fields:

Field Type Description
fileId string (canonical UUID) Client-generated before envelope construction
collectionId string (UUID) Target collection
metadataEnvelope string (canonical base64) DriveEnvelopeV1 metadata record bound to file, collection, epoch, and revision 1
fileKeyEnvelope string (canonical base64) DriveEnvelopeV1 file-key record bound to file, collection, epoch, and revision 1
file binary Complete typed V1 Drive file blob (application/octet-stream)

The server obtains the current collection epoch itself and rejects malformed, noncanonical, relocated, stale-epoch, wrong-purpose, or wrong-revision envelopes. It also validates that the blob’s authenticated-format header binds the same file, collection and epoch before storage. It never accepts a server-generated replacement for fileId.

Response: 201 Created

{
"id": "<same client-generated uuid>"
}

List files in a collection.

Auth: Bearer JWT

Response: Array of file objects:

[
{
"id": "<uuid>",
"collectionId": "<uuid>",
"uploaderUserId": "<uuid>",
"metadataEnvelope": "<DriveEnvelopeV1 base64>",
"fileKeyEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"metadataRevision": 1,
"encryptedSizeBytes": 4096,
"createdAt": "2026-03-14T12:00:00Z",
"updatedAt": "2026-03-14T12:00:00Z"
}
]

encryptedSizeBytes is the size of the ciphertext blob on disk: a 48-byte typed Drive header, a 24-byte secretstream header, and at least one frame with a 17-byte authentication/tag overhead.


Replace only the authenticated metadata envelope. The request must advance the stored revision by exactly one; gaps, rollback, replay, a wrong file or collection binding, and a stale epoch return 409 or 400 without changing the row.

{
"metadataEnvelope": "<DriveEnvelopeV1 base64>",
"metadataRevision": 2
}

Download the encrypted content of a file.

Auth: Bearer JWT

Response: Raw binary (application/octet-stream) — the encrypted file bytes.


Move a file to the trash (soft delete). The file disappears from every normal endpoint but keeps counting against quota; restore or purge it via the Trash endpoints. Permanent deletion happens from the trash — explicitly, or automatically after TRASH_RETENTION_DAYS (default 30).

Auth: Bearer JWT (collection owner, or the uploader holding a canDelete share)

Response: 204 No Content.


Trash is owner-scoped: an item lives in the trash of the user who owns the collection it belongs to (a share recipient’s delete lands in the owner’s trash — the Google Drive model). Every entry is a trash root: a deleted file, or a deleted folder carrying its whole subtree. A background sweeper purges roots older than TRASH_RETENTION_DAYS (default 30; 0 disables the sweeper). Federated Drive deletes (DELETE /api/fed/drive/files/:fileId) remain permanent — there is no cross-server trash.

List the caller’s trash roots, newest first. Folder rows carry the complete authenticated owner collection record. File rows additionally carry the parent collection’s owner-key envelope and signed epoch record so the metadata chain can be verified even when the collection is absent from the live listing.

Auth: Bearer JWT

Response: 200 OK

{
"folders": [
{
"id": "<uuid>",
"ownerUserId": "<uuid>",
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"ownerKeyEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"nameRevision": 1,
"epochStatement": "<CollectionEpochStatementV1 base64>",
"epochStatementHash": "<lowercase SHA-256 hex>",
"color": "blue",
"items": 12,
"deletedAt": "2026-06-11T11:22:33Z"
}
],
"files": [
{
"id": "<uuid>",
"collectionId": "<uuid>",
"metadataEnvelope": "<DriveEnvelopeV1 base64>",
"fileKeyEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"metadataRevision": 1,
"collectionOwnerUserId": "<uuid>",
"collectionOwnerKeyEnvelope": "<DriveEnvelopeV1 base64>",
"collectionKeyEpoch": 1,
"collectionEpochStatement": "<CollectionEpochStatementV1 base64>",
"collectionEpochStatementHash": "<lowercase SHA-256 hex>",
"deletedAt": "2026-06-11T11:22:33Z"
}
]
}

items is the number of files trashed together with the folder (its subtree).

Put a trash root back where it was. Restoring a folder restores its whole subtree; if its original parent is gone or still trashed, it comes back at the top level. Restoring a file whose folder is still in the trash returns 409 Conflict (restore the folder instead).

Auth: Bearer JWT (owner only)

Response: 200 OK {"message": "restored"} · 409 Conflict when the parent folder is still trashed.

Permanently purge one trash root: DB rows, S3 blobs (including version/asset children), and the held quota. Irreversible.

Auth: Bearer JWT (owner only)

Response: 204 No Content.

Empty the caller’s whole trash. Irreversible.

Auth: Bearer JWT

Response: 204 No Content.


Create a public share link for one collection. The link key used to open the typed collection-key envelope lives only in the URL fragment; the server never sees it.

Auth: Bearer JWT

Request body:

{
"shareType": "collection",
"targetId": "<uuid>",
"collectionKeyEnvelope": "<DriveEnvelopeV1 base64>",
"expiresInHours": 48
}

V1 accepts only shareType: "collection". expiresInHours is optional; omit or send null for no expiry. collectionKeyEnvelope uses the public-link purpose and binds the target collection, owner and current collection epoch. Malformed, relocated, stale-epoch or wrong-purpose envelopes are rejected before storage.

Response: 201 Created

{
"id": "<uuid>",
"token": "<random-token>"
}

The client builds the share URL as <SERVER_URL>/s/<token>#key=<base64-link-key>; the server returns only the token.


Get metadata for a public share. The wrapped collection key is included; the link key needed to unwrap it lives only in the URL fragment held by the recipient.

Auth: None

Response:

{
"id": "<uuid>",
"shareType": "collection",
"targetId": "<uuid>",
"collectionKeyEnvelope": "<DriveEnvelopeV1 base64>",
"collectionKeyEpoch": 1,
"ownerUserId": "<uuid>",
"expiresAt": "2026-04-01T00:00:00Z"
}

expiresAt is null when the share has no expiry. Returns 410 Gone if the share has expired.


List files in a public share.

Auth: None

Response: Array of file objects. Note: shape is similar to GET /api/collections/:id/files but omits uploaderUserId and updatedAt, and createdAt is serialized as a string (matches the database TIMESTAMP text form).

[
{
"id": "<uuid>",
"collectionId": "<uuid>",
"metadataEnvelope": "<DriveEnvelopeV1 base64>",
"fileKeyEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"metadataRevision": 1,
"encryptedSizeBytes": 4096,
"createdAt": "2026-03-14T12:00:00Z"
}
]

Returns 400 if the share targets a single file (use /download/:fileId instead), 410 if the share has expired.


Download a file from a public share. Streams the encrypted blob (application/octet-stream) through the backend; the client decrypts it with the link key from the URL fragment.

Auth: None (the token is the capability)

Response: the raw encrypted bytes.

Returns 410 Gone if the share has expired, 403 if the file does not belong to the shared target.


The current Chat protocol is described in docs/chat-protocol.md, media in docs/chat-media.md, and continuous display-history recovery in docs/chat-backup.md. Clients run libsignal Direct Chat and OpenMLS private groups; servers retain public routing/identity material, operational metadata, and opaque ciphertext rather than protected plaintext. All endpoints require a Bearer JWT unless noted. Wire types live in crates/kutup-chat-proto and are fully described by the OpenAPI document.

Register the calling client as a chat device. The server assigns the lowest free device id and enforces its configured active-device limit, never exceeding the V1 hard cap of 10. Body: suite (1), registrationId (libsignal, 1..16383), identityKey, signedPreKey (signature required), lastResortKyberPreKey (bundles are never non-PQ), optional oneTimePreKeys[] / oneTimeKyberPreKeys[] pools, optional name. All key material is canonical base64.

Response: 200 OK{ "deviceId": 1 } · 409 when the configured active-device limit is reached.

The caller’s chat devices: { "devices": [{ "deviceId", "suite", "name", "createdAt", "lastSeenAt" }] }.

Rename one of the caller’s registered Chat installations. The body is { "name": "Work laptop" }; names are trimmed, required, limited to 64 Unicode characters, and cannot contain control characters. This changes only account-private display metadata—the numeric device ID, encryption keys, sessions, signed device manifest, and protected history remain unchanged. 204.

Revoke a chat device — hard delete; prekey pools and mailbox rows are removed, and live sockets close. It does not delete account-level Chat history backup. 204.

Idempotently provision the always-on Chat archive after account recovery. The body contains a typed account-master-key envelope for the random backup root and an account-authority-signed manifest-signer authorization. The server validates public bindings and signatures but never receives the root key.

Return provisioning state, current signed manifest/cursor, latest server-acknowledged protected time, and dedicated Chat quota usage split into message history, administrator-retained delivery media, and history media.

Append one idempotent encrypted event segment. Requests bind a random operation ID, active source device, contiguous per-device sequence and digest chain, account-manifest sequence, ciphertext digest and length. The server assigns the monotonic account cursor. 507 means the durable local outbox is not yet protected; clients must show the latest acknowledged time and storage action.

GET /api/chat/backup/segments?after=N&limit=N

Section titled “GET /api/chat/backup/segments?after=N&limit=N”

Page the complete ordered encrypted event tail. Restoring it does not advance a mailbox cursor or establish Direct/MLS protocol state.

Stage a typed encrypted compacted base using multipart metadata and ciphertext fields. Staging is bounded and expires after 24 hours. Temporary overlap with the current archive is allowed only when the post-CAS footprint fits the account’s administrator-configured Chat quota.

Stream only the currently committed encrypted base and its ciphertext digest.

Verify and compare-and-swap a signed manifest against the exact current generation, cursor and digest. Commit atomically activates the staged base and reconciled media set, then releases superseded message/media quota.

Copy an account-owned ordinary Chat-media ciphertext into a padded, backup-specific outer encryption. The client supplies only the derived outer key; the original attachment plaintext/key never reaches the server.

Multipart direct-upload fallback for a verified outer-encrypted media object retained locally after its ordinary delivery object has expired.

Lazily stream one opaque history-media object. Clients validate its typed header, digest, secretstream final tag, source length and zero padding before placing the inner Chat-media ciphertext in the private cache.

POST /api/chat/backup/media/reconciliation

Section titled “POST /api/chat/backup/media/reconciliation”

Page the exact, digest-bound media reference set for a target generation before manifest CAS. Unreferenced history media is garbage-collected only at commit.

There is no Chat-backup DELETE route, ordinary disable action, or device-transfer fallback. Account deletion and administrator loss-recovery wipe invoke internal lifecycle cleanup that transactionally removes backup database state/quota and deletes its object-storage prefix.

Rotate signedPreKey / lastResortKyberPreKey and/or upload more one-time prekeys (only fields present are changed; pool inserts are idempotent per keyId).

Remaining one-time pool sizes: { "oneTimePreKeys": n, "oneTimeKyberPreKeys": n } — clients replenish below a threshold.

Publish one complete AccountManifestV1, signed by the account self-authority. Sequences advance by exactly one and hash-link to the preceding signed record. The authority and incarnation cannot change inside a chain. Every signed device id, registration id, direct-chat identity key, MLS credential/delivery key and suite must exactly match a registered server tuple. Registered rows not selected by the authority-signed manifest are pruned atomically; this recovers a crash between device registration and manifest publication without allowing an unmanifested device to become trusted. The Drive HPKE and share-signing keys are account-scoped fields in the same manifest. Publication atomically updates the current head and immutable history; exact replay is idempotent. Malformed signatures, gaps, forks, authority replacement, or a declared device-key conflict return 409.

Return the current account-signed device manifest for a local user. This direct manifest endpoint is authenticated. A client independently verifies its signature, authority continuity, and hash chain rather than trusting a server status label.

GET /api/chat/users/{username}/manifest-history

Section titled “GET /api/chat/users/{username}/manifest-history”

Return complete, individually account-signed manifest records for an exact inclusive range. Query parameters are fromSequence, toSequence, and an optional pageFromSequence. Pages contain at most 64 records and carry the next exact sequence, never an opaque server cursor. Missing, duplicated, reordered, cross-incarnation, or partially verified history does not clear a client gap and blocks new sends to that peer. A remote canonical address is resolved only through the authenticated federation transport.

Owner-only recovery of the current opaque encrypted profile, including the random profile key wrapped under the account master key for linked-device recovery. Returns 404 until a profile has been published. The response carries suite: 1, canonical account, version, revision, source device, and canonical ProfileEnvelopeV1 fields. The owner-only response includes the wrapped profile-key envelope; peer responses omit it.

Publish a new opaque encrypted display-name/avatar profile. The server sees only ciphertext, a profile-key-derived version, an access-key verifier, a master-key-wrapped profile key, revision, and source device. Revision plus source-device ordering resolves concurrent linked-device writes; exact replay is idempotent and a stale/conflicting revision returns 409. Every encrypted field binds the exact profile suite, authenticated canonical account, version, revision, source device and purpose. Unknown suites, noncanonical base64, malformed headers, relocation and trailing bytes return 400 before persistence.

GET /api/chat/users/{username}/profile/{version}

Section titled “GET /api/chat/users/{username}/profile/{version}”

Capability-gated encrypted profile lookup for a local or federated canonical address. The caller supplies the profile access key in the dedicated request header rather than the URL. A wrong version or capability is deliberately indistinguishable from a missing profile and returns 404.

Return PQXDH prekey bundles for every active Chat device of username (a logical message encrypts to all of them), together with the exact complete account-signed manifest. Each bundle carries identityKey, signedPreKey, kyberPreKey (a consumed one-time ML-KEM-1024 prekey, or the reusable last-resort key when the pool is empty), and optionally a consumed one-time EC prekey. The client verifies manifest and bundle identity equality before creating sessions. Fetches are limited to 30/min per authenticated account (RATE_LIMIT_CHAT_KEYS_PER_MIN) with a coarse 120/min IP wall (RATE_LIMIT_CHAT_KEYS_IP_PER_MIN).

Deliver one logical message as per-device ciphertexts: { "senderDeviceId": n, "envelopes": [{ "deviceId", "registrationId", "envelopeType": "preKey"|"message", "suite": 1, "content": "<base64>" }] }. The device set must exactly match the recipient’s current devices — ids and registration ids — or the send fails with 409 { "missingDevices": [], "staleDevices": [], "extraDevices": [] } (Signal’s contract: no device can be silently skipped, and reinstalled devices are detected). Stored envelopes are also pushed to the recipient’s live chat sockets.

Deliver an encrypted sent transcript to every other active device belonging to the authenticated account. The sending device is excluded from the exact device-set check; an empty destination set succeeds for a single-device account. Note to Self and ordinary outgoing-message synchronization use this same idempotent mailbox path.

GET /api/chat/messages?deviceId=N&limit=100

Section titled “GET /api/chat/messages?deviceId=N&limit=100”

Drain the device’s mailbox, oldest first (max 500/page): { "envelopes": [{ "id", "sender", "senderDeviceId", "envelopeType", "suite", "content", "serverTimestamp" }], "more": bool }. Envelopes stay stored until acked.

{ "ids": ["<uuid>", …] } → deletes processed envelopes; returns { "acked": n }.

Mint a random, one-time browser WebSocket ticket bound to the authenticated user and chat device. The ticket expires in 60 seconds and is returned as { "ticket", "expiresAt" }.

WebSocket. Browsers use the one-time ticket; native clients instead send Authorization: Bearer … with ?deviceId=N. Reusable JWT query parameters are rejected. Server → client JSON frames: { "type": "drainMailbox" } once on connect (fetch the backlog over REST), then { "type": "envelope", "envelope": {…} } per newly arrived message. Acks stay on REST — the mailbox is the source of truth.


Chat Federation — Server-to-Server Endpoints

Section titled “Chat Federation — Server-to-Server Endpoints”

Chat federation is present only when the administrator configures a persistent v2 identity. Admission follows the global stop plus Chat’s disabled, allowlist, blocklist, or open mode, trust floor, and directional domain rules. Policy is evaluated before DNS/discovery or delivery. Admitted traffic must then pass signed discovery and complete identity-history verification, persistent pin/rotation/quarantine policy, public-HTTPS and DNS/SSRF checks, strict RFC 9421/9530 request and response signatures, replay reservation, request/body bounds, protocol checks, and coarse rate limiting. The normative common transport contract is in docs/federation-protocol.md.

The v2 Ed25519 signature binds method, authority, path/query, exact-body digest, content type, federation version, feature, origin, destination, key ID, nonce, and a maximum five-minute lifetime. Authenticated responses bind those request components in reverse origin/destination order. A destination mismatch, unknown pinned key, bad signature, nonce/content conflict, or invalid time window is rejected.

Signed v2 discovery containing the canonical server, delegated apiBase, typed capabilities, embedded current identity document and hash, validity window, and signature. Returns 404 when the shared identity is absent, the global stop is active, or both Chat and Drive are disabled. An individually disabled feature is omitted from capabilities while the other remains discoverable. Production discovery and delegated API targets require public HTTPS.

GET /.well-known/kutup/federation/identity/{sequence}.json

Section titled “GET /.well-known/kutup/federation/identity/{sequence}.json”

Return one immutable identity document from the locally verified, contiguous history. Genesis is sequence zero. Every rotation hash-links its predecessor and is signed by both old and new keys.

Authenticated server-to-server directory lookup. Returns the remote user’s account-signed complete manifest and replay-safe last-resort PQ bundles. It deliberately does not consume one-time prekeys, so a replayed signed read cannot exhaust the remote recipient’s pool.

GET /api/fed/chat/users/{username}/manifest

Section titled “GET /api/fed/chat/users/{username}/manifest”

Return the current complete account-signed manifest through an authenticated, origin- and destination-bound federation response.

GET /api/fed/chat/users/{username}/manifest-history

Section titled “GET /api/fed/chat/users/{username}/manifest-history”

Return the same exact-sequence, maximum-64-entry history pages as the local route. The signed request URI binds fromSequence, toSequence, and optional pageFromSequence.

GET /api/fed/chat/users/{username}/profile/{version}

Section titled “GET /api/fed/chat/users/{username}/profile/{version}”

Authenticated proxy lookup for an opaque encrypted profile. The server-to- server signature authenticates and destination-binds the originating homeserver; the separate profile access-key header remains the end-to-end capability. Wrong capabilities return 404.

Receive one signed, ordered FederatedChatTransaction. The receiver enforces a contiguous per-origin sequence, exact recipient device set, canonical origin/sender and destination/recipient binding, and transaction-id replay safety. Mailbox rows, the stored idempotent response, and the sequence high-water mark commit atomically. Exact replay returns the stored response; device mismatch or sequence gap returns typed 409 data so the origin can refresh/re-encrypt or replay the missing retained transaction.


Drive Federation — Local Authenticated Endpoints

Section titled “Drive Federation — Local Authenticated Endpoints”

These routes let a local browser interact with a remote encrypted share. The local server performs all remote discovery, admission, identity pinning, and message authentication; browsers never submit a remote API URL.

Accept an invite after the browser parses its fragment.

Auth: Bearer JWT

{
"server": "sharer.example.com",
"capability": "<base64url>"
}

The server fetches the signed invite and verifies that its source domain and intended recipient username match the authenticated local account. Success is 201 Created with:

{
"id": "<uuid>",
"remoteDomain": "sharer.example.com",
"remoteCollectionId": "<uuid>",
"namedShareEnvelope": "<NamedShareEnvelopeV1 base64>",
"nameEnvelope": "<DriveEnvelopeV1 base64>",
"keyEpoch": 1,
"nameRevision": 1,
"epochStatement": "<CollectionEpochStatementV1 base64>",
"epochStatementHash": "<lowercase SHA-256 hex>",
"ownerUserId": "<uuid>",
"ownerAccount": "alice@sharer.example.com",
"ownerIncarnationId": "<lowercase SHA-256 hex>",
"ownerSigningPublicKey": "<canonical base64, 32 bytes>",
"ownerAuthorityPublicKey": "<canonical base64, 32 bytes>",
"canUpload": true,
"canDelete": false,
"uploadQuotaBytes": null,
"createdAt": "<RFC3339>"
}

The retained remote capability is secret server-side state and is omitted from all responses.

List the authenticated user’s accepted remote shares. Returns the same public shape as acceptance, without any capability.

DELETE /api/drive/federation/shares/:shareId

Section titled “DELETE /api/drive/federation/shares/:shareId”

Remove one accepted remote share for the authenticated user. Returns 204.

GET /api/drive/federation/shares/:shareId/files

Section titled “GET /api/drive/federation/shares/:shareId/files”

Return the signed and verified remote ciphertext metadata list.

GET /api/drive/federation/shares/:shareId/files/:fileId/content

Section titled “GET /api/drive/federation/shares/:shareId/files/:fileId/content”

Stream remote ciphertext. The local server first spools and hashes the complete remote response, verifies its signed RFC 9530 digest under the pinned peer key, then releases the verified stream to the browser.

POST /api/drive/federation/shares/:shareId/files

Section titled “POST /api/drive/federation/shares/:shareId/files”

Upload encrypted multipart fields (fileId, metadataEnvelope, fileKeyEnvelope, and file). Exact retries are idempotent and return the same 201 { "id": "<uuid>" } result.

DELETE /api/drive/federation/shares/:shareId/files/:fileId

Section titled “DELETE /api/drive/federation/shares/:shareId/files/:fileId”

Delete a remote ciphertext object when canDelete permits it. Exact retries are idempotent and return 204.


Drive Federation — Server-to-Server Endpoints

Section titled “Drive Federation — Server-to-Server Endpoints”

All /api/fed/drive/* routes require the unified drive.v1 RFC 9421/9530 request signature and return an authenticated response. Except for user lookup, they also require Kutup-Share-Capability; the capability is checked only after the request’s canonical origin domain has authenticated, and the share must be bound to that same domain.

Return { "username", "server", "publicKey" } for one active local user.

Return the encrypted collection metadata, wrapped key, intended recipient, and grants for the capability-authorized share.

List ciphertext file metadata for the capability-authorized collection.

Stream ciphertext with its precomputed signed content digest and exact length.

Store one encrypted multipart upload. The exact ciphertext is hashed while it is spooled and the digest is persisted with the file row. A stable request ID plus authenticated request hash provides persistent idempotency.

Delete one ciphertext file under a persistent idempotent mutation result.

The removed /api/fed/users, /api/fed/invites/*, /api/fed/shares/*, /api/fed-proxy/*, /api/collections/:id/share-federated, and /api/collections/fed-pubkey routes have no compatibility handlers and return 404.


All admin endpoints require the isAdmin flag on the JWT and share a stricter per-IP rate limit (120/min, RATE_LIMIT_ADMIN_PER_MIN; over-limit requests return 429).

Every mutating admin endpoint (create / update / delete user, force-disable 2FA, settings update) writes a row to the admin audit log — who did what to whom, when. The log is readable via GET /api/admin/activity below. Audit rows have no foreign keys and outlive the accounts they reference; the human-readable identities (emails, usernames) are snapshotted into the row’s payload at action time.

List all registered users.

Auth: Bearer JWT (admin)

Response: Array of user objects:

[
{
"id": "<uuid>",
"email": "alice@example.com",
"username": "alice",
"storageQuotaBytes": 10737418240,
"storageUsedBytes": 524288000,
"isAdmin": false,
"isActive": true,
"totpEnabled": false,
"createdAt": "2026-03-14T12:00:00Z",
"isProtected": false
}
]

isProtected is true for the break-glass admin (the account from the ADMIN_ACCOUNT env var). Protected users cannot be demoted, disabled, or deleted — the relevant mutations below return 403.


Create a user account (admin-initiated, bypasses registration settings). The user logs in with tempPassword and is then forced through the first-login setup flow to generate their own key bundle and recovery phrase.

Auth: Bearer JWT (admin)

Request body:

{
"email": "newuser@example.com",
"username": "newuser",
"tempPassword": "temporaryPassword",
"storageQuotaBytes": 10737418240
}

storageQuotaBytes is optional and defaults to 10 GB. Returns 201 Created {"message": "user created"}. 409 if the email or username is already taken.


Update a user. All fields are optional; only the ones present in the request are applied.

Auth: Bearer JWT (admin)

Request body:

{
"storageQuotaBytes": 21474836480,
"isActive": true,
"isAdmin": false
}

isAdmin promotes/demotes the user. The change is reflected in JWT claims on the user’s next token refresh.

Response: 200 OK {"message": "updated"}. 403 if the request would demote or disable the break-glass admin; 400 if it would leave zero usable admins.


Delete a user and all their data.

Auth: Bearer JWT (admin)

Response: 204 No Content. 403 if the target is the break-glass admin.


Force-disable a user’s TOTP two-factor authentication — an admin override for users locked out of their authenticator. Clears totp_secret and totp_enabled; the account becomes password-only until the user re-enables 2FA from their Security page. Allowed on any user, including the break-glass admin.

Auth: Bearer JWT (admin)

Response: 200 OK {"message": "2fa disabled"}. 404 if the user does not exist.


POST /api/admin/users/:id/rotate-temp-password

Section titled “POST /api/admin/users/:id/rotate-temp-password”

Replace the temporary password of an account still in first-login state (isFirstLogin: true). Such an account has no E2EE key material yet, so nothing is destroyed. For an established account this returns 409 — under E2EE the server cannot reset a password without destroying the user’s data; the user self-serves via POST /api/auth/recover (recovery phrase), or the admin wipes (below). Design: docs/research/10-admin-password-reset.md.

Auth: Bearer JWT (admin)

Request body: {"tempPassword": "<new temp password>"}

Response: 200 OK {"message": "temp password rotated"} · 409 when the user has completed setup.


Destructive account reset for a user who lost both their password and their recovery phrase (their data is cryptographically unreachable anyway). Purges every collection the user owns — files, versions, assets, S3 blobs, share links, trash — erases the key bundle, disables TOTP, revokes collab device keys and received shares, then resets the account to first-login with the supplied temp password. Email, username, and quota survive. Irreversible. Refused (403) for the break-glass admin.

Auth: Bearer JWT (admin)

Request body: {"tempPassword": "<new temp password>"}

Response: 200 OK {"message": "account wiped"}.


Return aggregate server statistics.

Auth: Bearer JWT (admin)

Response:

{
"totalUsers": 42,
"activeUsers": 39,
"totalFiles": 1234,
"totalStorageUsedBytes": 107374182400,
"totalCollections": 87,
"storageTotalBytes": 536870912000,
"storageBackendUsedBytes": 268435456000
}

totalStorageUsedBytes is the DB sum of per-account usage. storageTotalBytes and storageBackendUsedBytes are the storage backend’s real total capacity and on-disk usage, probed live from the SeaweedFS master (SEAWEEDFS_MASTER_URL); storageTotalBytes falls back to the STORAGE_TOTAL_BYTES env var, and both are 0 when no probe or env var is configured.


The admin audit-log feed, newest first.

Auth: Bearer JWT (admin)

Query parameters: limit (1–100, default 50) · before (cursor: return entries with id lower than this — pass the previous page’s nextBefore) · actionPrefix (for example federation.) · domain (an exact canonical federation domain in the structured event payload). Filters compose and the cursor remains stable.

Response:

{
"entries": [
{
"id": 7,
"action": "user.create",
"adminUserId": "<uuid>",
"adminEmail": "admin@example.com",
"adminUsername": "admin",
"targetUserId": "<uuid>",
"targetEmail": "newuser@example.com",
"payload": { "email": "newuser@example.com", "username": "newuser", "storageQuotaBytes": 10737418240 },
"occurredAt": "2026-06-11T11:22:33Z"
}
],
"nextBefore": null
}

Actions include user/settings changes plus federation.policy.*, federation.rule.*, federation.peer.*, and federation.identity.*. Identity pin, authenticated rotation, quarantine, verification, and break-glass re-pin events retain full fingerprints, sequences, and reasons but never private signing keys or Drive capabilities. adminEmail/targetEmail are the live identities and become null once the referenced account is deleted — the payload snapshot keeps the trail readable. nextBefore is non-null while older pages remain.

Export the same filtered audit stream as spreadsheet-safe UTF-8 CSV. It accepts before, actionPrefix, and domain; limit is clamped to 1–5000 and defaults to 1000. Cells beginning with spreadsheet formula markers are neutralized and the response is downloaded as kutup-admin-audit.csv.


Return current global server settings.

Auth: Bearer JWT (admin)

Response:

{
"registrationEnabled": true
}

Update global server settings.

Auth: Bearer JWT (admin)

Request body:

{
"registrationEnabled": false
}

Response: Same shape as GET /api/admin/settings.


Return the unified federation identity, feature policies, domain rules, and persisted peer trust/discovery state. Rules remain visible while inactive.

Auth: Bearer JWT (admin)

{
"configured": true,
"serverName": "chat.example.com",
"fingerprint": "64-lowercase-hex-characters",
"fingerprintDisplay": "grouped full fingerprint",
"identitySequence": 0,
"capabilities": ["chat.v1", "drive.v1", "identity.v1"],
"globalEnabled": true,
"features": [
{ "feature": "chat", "mode": "allowlist", "minimumTrust": "verified" },
{ "feature": "drive", "mode": "allowlist", "minimumTrust": "verified" }
],
"rules": [
{
"domain": "friend.example",
"feature": "chat",
"inbound": "allow",
"outbound": "allow",
"trustRequirement": "inherit",
"createdAt": "2026-07-20T10:00:00Z",
"updatedAt": "2026-07-20T10:00:00Z"
}
],
"peers": [
{
"domain": "friend.example",
"trust": "tofu",
"sequence": 0,
"fingerprint": "64-lowercase-hex-characters",
"fingerprintDisplay": "grouped full fingerprint",
"apiBase": "https://friend.example",
"capabilities": ["chat.v1", "drive.v1", "identity.v1"],
"firstSeenAt": "2026-07-20T10:00:00Z",
"lastSeenAt": "2026-07-20T10:00:00Z",
"verifiedAt": null,
"discoveryExpiresAt": "2026-07-20T11:00:00Z",
"quarantineReason": null,
"pendingFingerprint": null,
"lastDiscoveryError": null,
"diagnostics": {
"chatPendingTransactions": 0,
"chatMismatchTransactions": 0,
"driveIncomingShares": 1,
"driveOutgoingShares": 0
}
}
],
"operational": {
"peerTotal": 1,
"tofuPeers": 1,
"verifiedPeers": 0,
"quarantinedPeers": 0,
"chatPendingTransactions": 0,
"chatMismatchTransactions": 0,
"oldestChatPendingAt": null,
"driveIncomingShares": 1,
"driveOutgoingShares": 0,
"activeReplayReservations": 0
}
}

configured reports whether the persistent v2 identity exists. Fingerprints are always returned in full; the grouped form is display-only.

Update the emergency stop and one feature policy:

{
"globalEnabled": true,
"feature": "chat",
"mode": "disabled|allowlist|blocklist|open",
"minimumTrust": "tofu|verified"
}

Returns the full control-plane response, wakes the Chat outbox, and audits federation.policy.update. The global stop denies every feature. A disabled Chat feature also hides public discovery. allowlist requires an explicit directional allow, blocklist permits inherited directions except explicit blocks, and open permits every admitted direction. Admission never bypasses the effective trust requirement.

PUT /api/admin/federation/rules/:feature/:domain

Section titled “PUT /api/admin/federation/rules/:feature/:domain”

Create or replace a rule for chat or drive and a canonical lowercase DNS domain. The local domain cannot be added.

{
"inbound": "inherit|allow|block",
"outbound": "inherit|allow|block",
"trustRequirement": "inherit|tofu|verified"
}

Returns the full response, wakes pending Chat delivery for the domain, and audits federation.rule.upsert.

DELETE /api/admin/federation/rules/:feature/:domain

Section titled “DELETE /api/admin/federation/rules/:feature/:domain”

Delete the feature-scoped rule. Returns the full response, or 404 when the rule does not exist, wakes pending Chat delivery, and audits federation.rule.delete.

POST /api/admin/federation/peers/:domain/verify

Section titled “POST /api/admin/federation/peers/:domain/verify”

Promote the current TOFU pin to verified trust after comparing its full fingerprint through an independent channel. Body:

{ "fingerprint": "64-lowercase-hex-characters" }

POST /api/admin/federation/peers/:domain/retry

Section titled “POST /api/admin/federation/peers/:domain/retry”

Evict positive and negative discovery caches, retry authenticated discovery through either enabled Chat or Drive capability, and wake pending Chat delivery. The returned control-plane state exposes the new discovery result or lastDiscoveryError. The outcome is audited as federation.peer.retry.

Retry up to 100 selected canonical domains independently. One failed peer does not abort the others.

{ "domains": ["one.example", "two.example"] }

The response contains {domain, refreshed, error} per peer and the complete batch result is retained in federation.peer.retry-bulk audit evidence.

GET /api/admin/federation/peers/:domain/evidence

Section titled “GET /api/admin/federation/peers/:domain/evidence”

Return the immutable accepted, superseded, and quarantined signed identity documents preserved by the generic trust store. Each entry includes sequence, document hash, full fingerprint, acceptance state, recorded time, and the exact public signed document. The response also identifies the current and pending document hashes and quarantine reason. Results are newest-first and bounded to 200 documents with truncated: true when older history exists. This endpoint does not expose federation private keys or Drive capabilities.

POST /api/admin/federation/peers/:domain/repin

Section titled “POST /api/admin/federation/peers/:domain/repin”

Break-glass replacement for a quarantined competing identity only. It requires the exact old fingerprint, pending new fingerprint, and domain:

{
"oldFingerprint": "64-lowercase-hex-characters",
"newFingerprint": "64-lowercase-hex-characters",
"confirmDomain": "friend.example"
}

The re-pin is persisted with administrator identity and old/new evidence; it is not an automatic fallback for failed rotation.


Per-device Ed25519 signing keys for collaborative-edit frame signing. Each browser tab session creates one device row; CLI sessions persist across runs.

Register a device signing key. Required before opening any collaborative-edit WebSocket. Auth: Bearer JWT. Body: {publicSigning: <base64-32>, label?: string, authSig: <base64>, timestamp: <unix-seconds>}. AuthSig is recorded but not validated in v1; the JWT is the trust anchor. Response 201: {deviceId, label, createdAt}

List the user’s devices. Response: array of {deviceId, label, isActive, createdAt, lastSeenAt}.

Revoke a device. Closes any open WebSocket connections from that device. Returns 404 if the device is already inactive (idempotent state-transition semantics). Response: 204.


WebSocket upgrade. Auth via Authorization: Bearer ... header or ?token=...&deviceId=N query (browsers can’t set custom headers on the initial WS handshake).

PreUpgrade validates: JWT (rejects setup/pre-auth tokens), file access (owner OR collection-share recipient), device registration (must belong to user, must be active). Failures return HTTP 401/403/404 BEFORE the WS handshake completes.

On accept the server sends a JSON hello {type, fileId, currentDocKeyId, headSeq, peers: [{deviceId, userId}]}. Client replies with JSON {type: "resume", lastSeenSeq: K}. Server replays binary CollabFrames from seq K+1 to head, then enters bidirectional binary mode. CollabFrameSuiteId = 1 is the canonical Rust-owned KUTPCF1\0 format documented in docs/v1-format-inventory.md; the server rejects an unknown suite, malformed length, invalid device signature, or any file/collection/epoch/document-generation mismatch.

Upload one whiteboard image as multipart field file. The part must be a complete DriveEnvelopeV1 purpose-6 asset envelope for the exact live file, collection, current collection-key epoch and path assetId. Asset IDs are 1–128 bytes and may not contain slash, backslash or ... Plaintext is capped at 25 MiB; oversized or invalid public envelopes are rejected before quota or object-storage mutation. Auth: Bearer JWT and file access. Response: 204 (content-addressed re-upload is idempotent).

Return the stored opaque asset envelope as application/octet-stream. The client opens it with the current collection key and the same exact context; key, file, collection, epoch, asset-ID relocation and tampering fail closed. Auth: Bearer JWT and file access.


List all versions newest-first. Response: array of {id, s3VersionId, storagePath, seqAtSnapshot, docKeyId, authorUserId, sizeBytes, label, keepForever, createdAt}.

GET /api/files/:fileId/versions/:vid/download

Section titled “GET /api/files/:fileId/versions/:vid/download”

Get the encrypted snapshot bytes for a specific version. Returns application/octet-stream. Headers: X-Kutup-Doc-Key-Id, X-Kutup-Seq, X-Kutup-S3-Version. The snapshot uses the same typed, file/collection/epoch-bound Drive file-blob format as an original file. There is no snapshot-specific legacy decoder.

Body: {label?: string, keepForever?: boolean} — set or unset. Response: updated version row.

Record a new snapshot. Server inserts the row and truncates file_update_log up to seqAtSnapshot. Body: {s3VersionId, storagePath, seqAtSnapshot, docKeyId, sizeBytes, label?, keepForever?} Response 201: {id} — the version row id.

Multipart file upload of a complete typed Drive file blob. Companion to POST /versions; validates its exact file, collection and current epoch binding, uploads the opaque bytes to S3 with versioning enabled, then returns the S3 metadata for the client to hand to /versions. Text, office and whiteboard snapshots use this one format. Response: {storagePath, s3VersionId}.