proven on the hosted testnet instance · 2026-09-13 · QA-14
The S3 surface
The root of https://testnet.permafrost.live is an S3 endpoint. Point an S3 client at it, hand it a
bearer credential, and the operations below work as they do anywhere else. This page is the list of
what is there, what is not, and what every object read carries back.
Authentication is a bearer, and only a bearer
Section titled “Authentication is a bearer, and only a bearer”Authorization: Bearer <your key or your token>The edge resolves that header, refuses the request if it cannot, and forwards it with the calling tenant attached. Two consequences a reader coming from AWS should know before writing any code:
- A SigV4-signed request is refused. Not because the signature is wrong — because the header is
not a bearer, so the edge never gets as far as a signature. The refusal is the
same
401as every other credential failure. - Pre-signed URLs do not work through this endpoint. A pre-signed URL carries its signature in
the query string and no
Authorizationheader at all, which is exactly the case the edge rejects.
SigV4 is the storage layer’s own scheme on its own port. That port is not reachable from outside
this instance, and the edge strips Authorization before the request reaches it, so a client’s
credential never arrives there in any form.
Most S3 SDKs need a small amount of persuading to send a bearer instead of signing. The Quickstart shows the shape.
Buckets
Section titled “Buckets”| Operation | Notes |
|---|---|
CreateBucket |
The creating tenant owns the bucket from that moment. No region argument. |
DeleteBucket |
The bucket must be empty. |
HeadBucket |
Answers for any existing bucket name — see Tenancy. |
ListBuckets |
Your own buckets only. |
Three calls answer rather than refuse, with a fixed default and nothing behind it:
GetBucketLocation returns the default region, and GetBucketAcl and GetObjectAcl return a
default ACL. Nothing is enforced from any of them. Access is decided by tenant, not by ACL.
Objects
Section titled “Objects”| Operation | Notes |
|---|---|
PutObject |
The core write. Single-object PUTs are encrypted at rest — see Encryption at rest. |
GetObject |
Supports Range, answering 206 with Content-Range. |
HeadObject |
Metadata only, including the certification headers below. |
DeleteObject |
Marks the object deleted; a subsequent read answers 404. |
DeleteObjects |
Bulk delete. |
ListObjects |
V1 listing. |
ListObjectsV2 |
V2 listing, with continuation tokens. |
Multipart
Section titled “Multipart”| Operation | Notes |
|---|---|
CreateMultipartUpload |
Validates the encryption-policy header, but see the caveat below. |
UploadPart |
Part bytes are not encrypted by this instance. |
CompleteMultipartUpload |
Writes the upload’s manifest. |
AbortMultipartUpload |
|
ListMultipartUploads |
|
ListParts |
A completed multipart object’s bytes resolve through its manifest and its part rows, not through one
blob. That is why a multipart object carries neither of the certification headers below, and why
x-amz-meta-permafrost-encrypt is accepted at initiate and then does nothing. A team that needs its
large media encrypted at rest encrypts the bytes before upload. Worked end to end in
Upload media in parts.
Request headers the instance acts on
Section titled “Request headers the instance acts on”| Header | Behaviour |
|---|---|
Content-Type |
Stored, and echoed back verbatim on read. |
Content-Length |
Required on PUT. |
Content-MD5 |
Validated when supplied; a mismatch is 400 InvalidDigest. |
Range |
Byte ranges on GET. |
x-amz-meta-* |
Stored as user metadata and returned on read. |
x-amz-meta-permafrost-encrypt |
The per-object encryption policy — Encryption at rest. |
Cache-Control, Content-Disposition and Content-Encoding are stored on the object but not acted
on. A stored Content-Disposition in particular is overwritten on the way out, for the reason in
Every stored-byte response below.
Not supported
Section titled “Not supported”Each of these refuses or is absent. None of them is a thing to plan around later — they are the shape of the surface today.
| Not there | What to do instead |
|---|---|
CopyObject |
Read the object and write it back under the new key. |
GetObjectTagging, PutObjectTagging |
Carry your own labels in x-amz-meta-*. |
Versioning — GetBucketVersioning, PutBucketVersioning, x-amz-version-id, delete markers |
Overwriting replaces the object. Version lineage lives on chain instead: see On-chain objects. |
ACL writes — PutBucketAcl, PutObjectAcl |
Access is by tenant. Tenancy. |
| Bucket policies, CORS configuration | — |
| Lifecycle rules | Storage on Walrus is leased; see Leases and renewal. |
Storage classes — x-amz-storage-class |
One tier. |
Conditional requests — If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since |
— |
| Pre-signed URLs | Hand your service the bearer, or proxy the read. |
| Replication, event notifications, object lock, inventory reports | — |
| AWS Signature V2 | — |
Content-Type: two postures
Section titled “Content-Type: two postures”| Posture | What is served | Where |
|---|---|---|
| Echo | the stored type, verbatim | the S3 surface — GET and HEAD on an object |
| Opaque | application/octet-stream, with the true stored type on X-Permafrost-Content-Type |
routes a browser never decodes as a media element |
The echo posture is contract, not an oversight. A <video src> or <img src> picks its decoder from
the served type, and because nosniff is set the browser may not fall back to sniffing — so
declaring everything opaque would break playback outright. Where a URL is only ever read by a program,
the attacker-chosen type is simply not echoed.
Every stored-byte response
Section titled “Every stored-byte response”Object bytes are served from an origin an application also uses, so a stored text/html or
image/svg+xml would otherwise execute as a page on that origin. Five headers are set on every
response that returns stored bytes, overwriting anything stored under the same name:
Content-Disposition: attachmentContent-Security-Policy: default-src 'none'; sandboxX-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrerThey ride along on 206 partial responses too, and on HEAD. What changes for a caller:
a direct top-level navigation to an object URL downloads the object instead of rendering it.
Subresource loads — <img>, <video>, <audio>, fetch() — ignore a disposition, so an embed
keeps working and so does a byte-range video seek.
Refusals are S3-shaped
Section titled “Refusals are S3-shaped”An S3 refusal is XML in the ordinary S3 error shape, with a Code, a Message, the Resource and a
request id.
| Code | Status | When |
|---|---|---|
AccessDenied |
403 | A cross-tenant operation, or a read whose key cannot be selected. |
NoSuchBucket |
404 | |
NoSuchKey |
404 | |
BucketAlreadyExists |
409 | |
BucketNotEmpty |
409 | DeleteBucket on a bucket that still holds objects. |
InvalidArgument |
400 | Including an unrecognised encryption-policy value. |
InvalidBucketName |
400 | |
InvalidDigest |
400 | Content-MD5 did not match the body. |
InvalidRange |
416 | |
MalformedXML |
400 | |
NotImplemented |
501 | |
InternalError |
500 | |
ServiceUnavailable |
503 | Walrus could not be reached. |
The refusals that are not S3-shaped are the ones the edge makes before the request reaches the storage layer — the credential and rate refusals. Those are JSON, and they are on Headers, status codes, 401 and 429.
- Headers, status codes, 401 and 429 — the certification headers and every status this instance returns.
- Limits: rate, quota, parts, timeouts — what bounds a request.
- Store, read back, and know when it is certified — this surface, run front to back.
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.