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.
backlex s3 create --name "backups"access key id BLXK7Q2M4PXR9TCVWsecret access key 9f2c… (shown once)mode read-writeprefix (whole workspace)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.
The bucket is the workspace
Section titled “The bucket is the workspace”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.
Credentials
Section titled “Credentials”| Option | What 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-only | Every mutating verb is refused. What a backup tool should hold. |
--disable | Stops working on the next request. There is no session to expire. |
What is implemented
Section titled “What is implemented”Chosen by what the tools above actually call:
| Operation | Notes |
|---|---|
GET / HEAD / PUT / DELETE object | |
ListObjectsV2 | prefix, delimiter, max-keys, continuation-token |
DeleteObjects (POST ?delete) | Per-key failures are reported in the body, as S3 does |
ListBuckets | Returns the one bucket |
| Multipart: create / upload part / complete / abort | Requires 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.
uploadIdis 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 isInvalidArgument(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.prefixis 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, soreport.still lists the reports. A traversal prefix isInvalidArgument(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.
Authentication
Section titled “Authentication”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.
Surfaces
Section titled “Surfaces”| Surface | Where |
|---|---|
| S3 protocol | /s3 |
| REST | /api/admin/s3-credentials |
| SDK | backlex.s3.* |
| MCP | s3.list_credentials, s3.create_credential, s3.update_credential, s3.delete_credential |
| CLI | backlex s3 … |