S3 compatibility
Your deployment’s endpoint root (for example https://c1.permafrost.live) is an S3 endpoint. Clients and SDKs work as against any complete S3 implementation, except as listed here.
The reference for the routes beside the S3 surface (verify, custody, your account) is shared with each deployment’s team.
Authentication
Section titled “Authentication”Two credentials work on every S3 call. Both reach your tenant.
A bearer
Section titled “A bearer”Authorization: Bearer <your token or key>A token from your issuer or an invite key (Connect your app); the Quickstart shows it with curl.
S3 access keys (AWS Signature Version 4)
Section titled “S3 access keys (AWS Signature Version 4)”For an unmodified S3 SDK or tool, mint a pair with your bearer:
POST /me/s3-keys → 201 {"access_key_id": "PFAK…", "secret_access_key": "…"}GET /me/s3-keys → your pairs and their status, never a secretDELETE /me/s3-keys/<id> → revokes that pairThe secret is shown once. Up to five pairs are active per tenant. Revoked pairs, and all pairs of a revoked tenant, stop working at once.
Configure the SDK with any region and service s3. Path-style addressing (/<bucket>/<key>) works on every deployment. Virtual-hosted addressing (<bucket>.<host>/<key>) works where your deployment has it turned on; ask at onboarding. Bucket names with dots use path-style. The examples are path-style:
s3 = boto3.client("s3", endpoint_url="https://c1.permafrost.live", region_name="us-east-1", aws_access_key_id="PFAK…", aws_secret_access_key="…", config=Config(s3={"addressing_style": "path"}))const s3 = new S3Client({ endpoint: "https://c1.permafrost.live", region: "us-east-1", forcePathStyle: true, credentials: { accessKeyId: "PFAK…", secretAccessKey: "…" } });AWS CLI: aws configure set default.s3.addressing_style path. Leave checksums, signing and multipart at the SDK’s defaults.
For virtual-hosted names, drop forcePathStyle in JavaScript and set addressing_style to "virtual" in boto3 and the AWS CLI. boto3 stays path-style on a custom endpoint unless told.
- Pre-signed URLs work for
GETandPUT, SigV4 only, up to seven days. Send any header the URL signed (such asx-amz-meta-permafrost-tier). boto3 needsConfig(signature_version="s3v4"); the AWS SDK for JavaScript v3 already uses SigV4. - The body is bound to the signature. A
PutObjectorUploadPartsigned with the payload’s sha256 gets400 BadDigestif the body doesn’t match. - Refused with
401, one answer for all: SigV2 (headers or URLs), session tokens (no STS), a clock more than fifteen minutes off, an expired or altered URL. - For end-to-end integrity on streaming (
aws-chunked) uploads, sendx-amz-meta-permafrost-sha256.
Supported operations
Section titled “Supported operations”| Buckets | Objects | Multipart |
|---|---|---|
CreateBucket |
PutObject |
CreateMultipartUpload |
DeleteBucket (empty) |
GetObject (with Range) |
UploadPart |
HeadBucket |
HeadObject |
CompleteMultipartUpload |
ListBuckets (your own) |
DeleteObject, DeleteObjects |
AbortMultipartUpload |
PutBucketPolicy (shared spaces) |
ListObjects, ListObjectsV2 |
ListMultipartUploads (with delimiter), ListParts |
PutBucketEncryption, GetBucketEncryption, DeleteBucketEncryption (your key service only) |
CopyObject onto the same key only |
A self-copy (same bucket and key, x-amz-metadata-directive: REPLACE) seals a held object, keeps a held one local, or moves a local one onto Walrus, single or multipart (Placement). aws s3api copy-object sends one CopyObject, changing the object in place. aws s3 cp and boto3’s managed copy() split a copy above 8 MiB into CreateMultipartUpload + UploadPartCopy + CompleteMultipartUpload, which also works but rewrites the object as multipart with a new ETag. Copying to a different key isn’t supported. ACL calls return a default and enforce nothing; access is by tenant. S3 Object Lock isn’t supported: a CreateBucket that asks for it (x-amz-bucket-object-lock-enabled: true) is refused with 400 InvalidRequest, and no bucket is created.
What DELETE does
Section titled “What DELETE does”DELETE removes an object from your deployment. Data already on Walrus stays readable by blob id until its storage span ends; its receipt is unaffected. To remove it everywhere, use custody (the key is destroyed) or ask for deletion at the end of a contract.
Permafrost headers
Section titled “Permafrost headers”All optional. Without them, an object is plain S3 with encryption at rest.
| Header | On | Meaning |
|---|---|---|
x-amz-meta-permafrost-tier |
PutObject, CreateMultipartUpload, self-copy |
LOCAL keeps the object on the deployment’s disk. HOT and ARCHIVE store it on Walrus; only HOT keeps a local copy for reads. Absent, the bucket’s default applies, multipart included. For a receipt with no stored copy: write LOCAL with a receipt, then DELETE (the receipt stays on chain). |
x-amz-meta-permafrost-hold |
PutObject, CreateMultipartUpload |
Seconds to hold before sealing; replaceable or deletable while held. Up to the deployment’s bound. Refused with LOCAL. |
x-amz-meta-permafrost-receipt |
PutObject, CreateMultipartUpload, self-copy |
none skips the receipt on Sui. On LOCAL, receipts are off unless you send default. A LOCAL object written without a receipt gets one when it moves to HOT or ARCHIVE only if that self-copy sends default. |
x-amz-meta-permafrost-encrypt |
PutObject, CreateMultipartUpload |
none stores this one object as plaintext (on a multipart upload: every part). Ignored on UploadPart. |
x-amz-meta-permafrost-epochs |
PutObject, CreateMultipartUpload |
How long to store on Walrus, instead of the deployment default. |
x-amz-meta-permafrost-retention |
CreateBucket |
The bucket’s policy as compact JSON: default tier, renewal, default span, what a lapsed hold becomes, the archive backend (archive_backend, one declared for the deployment), and (spaces only) a maximum age. Fixed at creation. |
x-amz-meta-permafrost-space |
CreateBucket |
1 makes the bucket a shared space (Shared spaces). |
x-amz-meta-permafrost-agent-space |
CreateBucket |
{"doom_seconds":N,"referee":"0x…"} makes the bucket an agent space. Both keys are required. |
x-amz-meta-permafrost-visibility |
PutObject in an agent space |
public or private. Required there, refused anywhere else. |
x-amz-meta-permafrost-fingerprint-kind |
PutObject, CreateMultipartUpload |
The receipt’s fingerprint: plaintext, salted or stored (Salted fingerprints). Defaults to salted when the stored copy is encrypted (deployment keys or your key service), plaintext with x-amz-meta-permafrost-encrypt: none and no key service. |
x-amz-meta-permafrost-sha256 |
PutObject, UploadPart |
The sha256 of this request’s body (64 hex). A mismatch gets 400 BadDigest; nothing is stored. |
HEAD and GET report:
x-amz-meta-permafrost-sync-state(PENDING,HELD,LOCAL,SYNCED, …).x-amz-meta-permafrost-state(LOCAL,SEALED,ARCHIVED); and, only when something is scheduled,x-amz-meta-permafrost-next(seal,keep,delete,release) andx-amz-meta-permafrost-next-at(Unix time in milliseconds) (Placement).- Once on Walrus:
x-amz-meta-permafrost-blob-id(a multipart object’s is its manifest’s),x-amz-meta-permafrost-kept-untilandx-amz-meta-permafrost-manifest-nft-id(its receipt on Sui). - Always:
x-amz-meta-permafrost-fingerprint-kindandx-amz-meta-permafrost-fingerprint(the value its receipt records).
An unrecognised value is refused with 400 InvalidArgument, naming the header, before any bytes are stored.
Statuses that matter
Section titled “Statuses that matter”| Status | Means |
|---|---|
400 BadDigest |
The body didn’t match x-amz-meta-permafrost-sha256. Nothing was stored; send it again. |
400 EntityTooLarge |
Over the deployment’s maximum object size (or a multipart upload’s parts would be), or a single PUT or one part to be sealed to Walrus is over the per-PUT limit: use multipart or smaller parts, or LOCAL. The message names the sizes. |
400 InvalidRequest |
A CreateBucket asking for S3 Object Lock, which isn’t supported. Nothing was created. |
400 EntityTooSmall |
At CompleteMultipartUpload: a part other than the last is under 5 MiB. Resend that part larger, or abort. |
401 |
Any credential failure, bearer or access key. Same answer every time, no reason given. |
403 |
A cross-tenant operation, or a revoked tenant. |
403 AccessDenied |
A write with a read-only key, or a read-only key asking for an S3 access-key pair. Use a read-write credential. |
403 QuotaExceeded |
Your tenant’s daily write budget would be exceeded. The body gives Limit, Used, Requested and ResetsAt (the next UTC midnight). Retrying sooner doesn’t help. |
403 ReadQuotaExceeded |
Your tenant’s daily read cap is reached; object GETs are refused until the next UTC midnight. The body is S3’s error XML with Limit, Used and ResetsAt, whatever the credential. Retrying sooner doesn’t help. |
404 |
Not found or not yours, indistinguishably, so existence never leaks. |
409 |
The key is still being stored; retry once HEAD reads SYNCED. |
410 |
A custody object whose key was destroyed. |
411 MissingContentLength |
Your tenant has a byte limit and the write declared no length (no Content-Length, nor x-amz-decoded-content-length on an aws-chunked upload, which a default AWS SDK upload sends). Send the length. |
413 EntityTooLarge |
Over your tenant’s maximum object size: for multipart, the part that takes the upload over it, or a CompleteMultipartUpload whose parts add up to more. The body gives ProposedSize and MaxSizeAllowed. |
413 |
A custody store larger than your tenant’s size ceiling. |
429 SlowDown |
Over your tenant’s request rate, or a new account’s first request while many accounts are being created at once. A short burst is held briefly and served at your rate instead; past that, AWS SDKs and the CLI back off on their own, and other clients honour Retry-After (seconds) or RetryAfterMs in the body. |
Limits
Section titled “Limits”- Rate. Each tenant has its own requests-per-second ceiling, shown on your deployment page.
- Bytes written. A deployment can set your tenant a maximum object size and a daily write budget (per UTC day). Both count each
PutObjector form upload body andUploadPartsent, even one that fails after acceptance. With a limit set, every write must declare its length.GET /mereportsmax_object_bytes,daily_bytes(null= no limit) andwritten_today_bytes. - Reads per day. A deployment can give your tenant a daily read cap on object
GETs per UTC day (a range read is one;HEAD, listings and writes don’t count). Every acceptedGETcounts, a404included, so the cap can run ahead of billed reads. Past it:403 ReadQuotaExceededuntil UTC midnight.GET /mereportslimits.daily_reads(null= no cap) andtoday.reads_admitted; your deployment page shows “N of CAP reads today”. Limits for your own users are yours to set in your application. - Size. Custody stores are checked against a per-tenant size ceiling before the body is read.
- Object size, per deployment. Each deployment sets its largest object and a limit on one
PUTto be sealed to Walrus, which also caps each part of a multipart upload (400 EntityTooLarge: use multipart, or smaller parts;LOCALis exempt). Your tenant’s own limits apply first. - Multipart. Every part except the last is at least 5 MiB (
EntityTooSmallotherwise), and none is over the per-PUTlimit above.UploadPartwrites nothing to Walrus; the upload is sealed after it completes, per its tier, with consecutive parts stored together up to that limit, so the part size your SDK picks barely changes what is stored. A part resent under the same number replaces the earlier one and counts once against your byte limits. - Hold. Up to the deployment’s bound.
Tenancy
Section titled “Tenancy”A tenant is a Sui address: your issuer token’s sub, or the address an invite was redeemed with. A bucket belongs to the tenant that created it, each object to the tenant that wrote it. Other tenants are refused on read, list, write and delete, and every refusal is logged.
Bucket names are visible across tenants of one deployment. Contents aren’t. Treat a bucket name as public.
Encryption
Section titled “Encryption”Objects are encrypted at rest, per tenant and server-side, by default, multipart parts included; x-amz-meta-permafrost-encrypt: none opts one out (on CreateMultipartUpload for a multipart upload).
To keep the deployment from reading your data, use your own key service (set up per dedicated deployment) or encrypt before upload; then the deployment never holds a key, and you keep your receipts. See Encryption and key control.
A bucket default is accepted only under your own key service (aws:kms with your key id); any other is refused with a 400 giving the reason. Customer-provided keys per request (SSE-C) aren’t supported.
Each custody object has its own key, which makes it individually deletable (Custody and deletion).
Permafrost runs on Sui testnet and Walrus testnet. Everything here describes a shipped testnet instance, not a production service.