Skip to content
Developer

S3-compatible endpoint

Point rclone, aws-cli, mc, Cyberduck or any backup tool at your workspace's objects — the protocol every storage tool already speaks.

backlex has object storage, a REST API for it, signed URLs and resumable uploads. What it did not have is the one protocol every existing tool already speaks.

Terminal window
backlex s3 create --name "backups"
access key id BLXK7Q2M4PXR9TCVW
secret access key 9f2c… (shown once)
mode read-write
prefix (whole workspace)
Terminal window
aws --endpoint-url https://your-instance/s3 \
s3 sync ./local s3://your-workspace/backups/

rclone, aws-cli, mc, Cyberduck, restic, duplicity — anything that talks S3 works, with no adapter on either side.

One bucket per workspace, named after the workspace slug. A SigV4 request carries no workspace header, so the credential names the workspace; the bucket in the URL is checked against it and never used to choose one. Changing the URL cannot reach another workspace’s objects.

It is a second protocol, not a second store

Section titled “It is a second protocol, not a second store”

Every object goes through the same storage adapter the REST API uses, and every key through the same validation. An object written by rclone appears in the admin’s file browser immediately, is served by /api/storage/<key>, counts toward the workspace’s storage usage, and is included in backups. A key the REST API would refuse is refused here identically, with the S3 error code for it.

Objects written over S3 are private. A protocol client has no way to say “publish this”, and defaulting to public would make an S3 upload quietly more exposed than the same upload through the API.

OptionWhat it does
--prefix team-a/The credential can only see and write keys under that prefix — in both directions, including listings. A request for a broader prefix is narrowed, never widened.
--read-onlyEvery mutating verb is refused. What a backup tool should hold.
--disableStops working on the next request. There is no session to expire.

Chosen by what the tools above actually call:

OperationNotes
GET / HEAD / PUT / DELETE object
ListObjectsV2prefix, delimiter, max-keys, continuation-token
DeleteObjects (POST ?delete)Per-key failures are reported in the body, as S3 does
ListBucketsReturns the one bucket
Multipart: create / upload part / complete / abortRequires a backend that can assemble parts; otherwise NotImplemented

Everything else — versioning, ACLs, bucket policies, lifecycle, replication, tagging, CORS configuration, website hosting — returns a proper S3 NotImplemented error rather than a 404. Each is a whole subsystem whose absence a tool copes with, where a wrong answer would make it misbehave.

ListParts answers with an empty list rather than a 501: every client that calls it does so to resume, and an empty list means “start over”, which every one of them handles. A 501 would abort the upload.

Two values on the wire are judged before they reach a backend

Section titled “Two values on the wire are judged before they reach a backend”

?uploadId= and ?prefix= are the only caller-supplied fragments this endpoint hands to a storage adapter without building an object key from them, so neither went through the key rules in docs/storage.md. On the fs backend both are resolved against the storage root.

  • uploadId is opaque here — it was minted by whichever backend is configured and is handed straight back to it — so the check is a deny list of what no backend ever issues: a separator, a parent-directory hop, a NUL, a control character, or more than 1 024 characters. Anything else is InvalidArgument (400). The fs adapter applies a stricter allow list of its own before the value becomes part of a filename, and re-checks the finished path against the storage root; a server-issued id (a UUID there, base64url-ish on R2 and S3) is unaffected, including one already in flight.
  • prefix is now judged by the same rule as a key minus the parts that only make sense for a whole filename — it may be empty, and its last segment is a partial name, so report. still lists the reports. A traversal prefix is InvalidArgument (400); previously it reached the adapter and surfaced as an unhandled 500.

A credential’s own --prefix is checked the same way, so a scope stored before these rules existed is refused loudly rather than passed through.

Both AWS SigV4 shapes are accepted: the Authorization header (what almost everything sends) and presigned query parameters (what a shared link is). There is no signature-version-2 fallback — a verifier that accepts a weaker scheme is exactly as strong as the weaker scheme.

Checked on every request: the signature itself (in constant time), a ±15 minute clock skew so a captured request is not replayable forever, a presigned URL’s own X-Amz-Expires, and that every header the signature claims to cover was actually sent.

Chunked uploads (Content-Encoding: aws-chunked, which mc and aws s3 cp use for large files) are accepted. The seed signature — the one over the headers — is verified exactly as for any other request; the per-chunk signatures are not. AWS’s own STREAMING-UNSIGNED-PAYLOAD-TRAILER does not sign chunks either, the request is already authenticated by the seed, and the body’s integrity in transit is TLS’s job.

A single signed PUT is capped at 64 MB, because the signature has to be checked against the whole body before any of it is stored. Larger objects go through multipart, which is what every client does anyway.

SurfaceWhere
S3 protocol/s3
REST/api/admin/s3-credentials
SDKbacklex.s3.*
MCPs3.list_credentials, s3.create_credential, s3.update_credential, s3.delete_credential
CLIbacklex s3 …