{"openapi":"3.1.0","info":{"title":"Walrus Console External API","version":"0.1.0","description":"Curated external API surface for third-party developers. Authenticate every request with `Authorization: Bearer hbr_…` (API keys are minted in the Console web app)."},"servers":[{"url":"https://api.testnet.console.walrus.xyz","description":"Testnet"},{"url":"https://api.console.walrus.xyz","description":"Mainnet"}],"tags":[{"name":"Spaces","description":"Space management"},{"name":"Buckets","description":"Bucket management"},{"name":"Files","description":"File metadata and upload"}],"paths":{"/api/v1/spaces":{"get":{"tags":["Spaces"],"summary":"List spaces","description":"List all spaces accessible to the authenticated user (personal + team). For an API key, storage_used and bucket_count count only the buckets the key can read, which is none in a space other than the key's own. bucket_limit stays the space's limit.","operationId":"listSpaces","security":[{"bearerAuth":[]}],"parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["personal","team"]},"description":"Filter by space type"}],"responses":{"200":{"description":"List of spaces","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SpaceListItem"}}}}}}},"401":{"description":"Not authenticated"}}}},"/api/v1/spaces/{id}/buckets":{"get":{"tags":["Buckets"],"summary":"List buckets in a space","description":"Session callers see every bucket in the space. An API key sees only the buckets it can reach: those delegated to it, those it created, and public ones. A key asked about a space it was not minted for gets an empty list rather than an error. The list includes folders still being created (`provisioning_state: pending_policy`), since each holds a slot under the plan's folder limit; an API key sees the pending folders it reserved itself. A pending folder cannot hold files yet. Finish it with POST /buckets/{id}/finalize, or discard it with DELETE /buckets/{id} unless `chain_landed` is true.","operationId":"listBucketsBySpace","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Space UUID"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":1000,"default":100},"description":"Maximum number of buckets to return."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor returned by a previous page."},{"name":"q","in":"query","required":false,"schema":{"type":"string","maxLength":255},"description":"Case-insensitive substring match on bucket name. `%` and `_` match literally; they are not wildcards. The query is Unicode-normalised to NFC before it is matched, like the stored names, so the composed and the decomposed spelling of `café` find the same rows."},{"name":"visibility","in":"query","required":false,"schema":{"type":"string","enum":["public","private"]},"description":"Filter buckets by visibility."},{"name":"sortField","in":"query","required":false,"schema":{"type":"string","enum":["name","date","storage"],"default":"date"},"description":"Field to sort by (date = createdAt)."},{"name":"sortOrder","in":"query","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"Sort direction."}],"responses":{"200":{"description":"Buckets in the space (paginated).","content":{"application/json":{"schema":{"type":"object","required":["buckets"],"properties":{"buckets":{"type":"array","items":{"$ref":"#/components/schemas/Bucket"}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null when no more results."}}}}}},"400":{"description":"Invalid UUID"},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Space not found"}}},"post":{"tags":["Buckets"],"summary":"Create bucket in a space","operationId":"createBucket","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Space UUID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","scope"],"properties":{"name":{"type":"string","minLength":1,"maxLength":100},"scope":{"type":"string","enum":["private"]},"expectedOwnerAddress":{"type":"string","description":"Optional client-pinned owner address; the reserve is rejected with 409 bucket_create_owner_mismatch when it does not match the server's record. Diagnostics only — the client-side config pin remains the enforcement point. Note: camelCase, unlike response fields."},"members":{"type":"array","maxItems":32,"description":"Optional client-authored roster of OTHER service accounts to grant on the new bucket. Present (including an empty array) means the PTB grants exactly this set and the space's active signers are ignored; omit it to let the server derive the roster from space state.","items":{"type":"object","required":["address","role"],"properties":{"address":{"type":"string","description":"Sui address of the member."},"role":{"type":"string","enum":["viewer","editor"]}}}}}}}}},"responses":{"201":{"description":"Private bucket reserved. Sign the returned `bytes` locally with the service key and POST the signature to POST /buckets/{id}/finalize to complete creation.","content":{"application/json":{"schema":{"type":"object","description":"Private bucket — reserve response. Sign `bytes` locally with the service key, then POST the signature to /buckets/{id}/finalize.","required":["bucket_id","bytes","digest","provisioning_state","owner_address","admin_signer_address"],"properties":{"bucket_id":{"type":"string","format":"uuid"},"bytes":{"type":"string","description":"Base64 sponsored transaction bytes."},"digest":{"type":"string"},"provisioning_state":{"type":"string","enum":["pending_policy"]},"owner_address":{"type":"string","description":"The bucket's human owner (A) the reserve was bound to — echoed so clients can verify their config-pinned owner (COMG-761)."},"admin_signer_address":{"type":["string","null"],"description":"Space's active Key-Admin signer baked into the sponsored PTB; null when the space has none."}}}}}},"400":{"description":"Validation error, or the client-authored `members` roster was rejected (code bucket_create_authored_members_invalid: duplicate address, the caller's own address, the owner address, an address that is not an active data-plane signer of the space (`key_admin` keys are excluded), a role that contradicts an active key's expected on-chain permission, or `members` sent on the session path)"},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Space not found"},"409":{"description":"Bucket name conflict (code bucket_name_conflict, with `bucket_id` naming the folder that holds the name: always for a session, and for an API key only when it is a pending folder that key reserved, so it can finalize or discard it), or expectedOwnerAddress does not match the server's record (code bucket_create_owner_mismatch)"},"422":{"description":"Plan limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlanLimitExceededResponse"}}}},"429":{"description":"Rate limited, always with `Retry-After`. Code `TooManyRequestsError` is the per-account create/resume limiter (shared with POST /buckets/{id}/resume); code `sponsor_cap_exceeded` is the daily cap on sponsored transactions, per account or across all accounts. A limiter store outage also answers 429 (fail closed)."}}}},"/api/v1/spaces/{id}/files":{"get":{"tags":["Files"],"summary":"List files across all buckets in a space (global search)","description":"Session callers see every file in the space. An API key sees only files in the buckets it can reach: those delegated to it, those it created, and public ones. A key asked about a space it was not minted for gets an empty list rather than an error.","operationId":"listSpaceFiles","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Space UUID"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous response's next_cursor"},{"name":"q","in":"query","schema":{"type":"string","maxLength":255},"description":"Case-insensitive substring match on file name. `%` and `_` match literally; they are not wildcards. The query is Unicode-normalised to NFC before it is matched, like the stored names, so the composed and the decomposed spelling of `café` find the same rows."},{"name":"sortField","in":"query","schema":{"type":"string","enum":["name","date","size","type"],"default":"date"}},{"name":"sortOrder","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}}],"responses":{"200":{"description":"File list (across all buckets in the space)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, null when no more results"}}}}}}}},"400":{"description":"Invalid request — malformed space UUID or query parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Space not found"}}}},"/api/v1/buckets/{id}":{"get":{"tags":["Buckets"],"summary":"Get bucket by ID","operationId":"getBucketById","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"}],"responses":{"200":{"description":"Bucket details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Bucket"}}}}}},"400":{"description":"Invalid UUID"},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Bucket not found"}}},"put":{"tags":["Buckets"],"summary":"Update bucket","operationId":"updateBucket","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBucketRequest"}}}},"responses":{"200":{"description":"Bucket updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UpdateBucketResponse"}}}}}},"400":{"description":"Validation error"},"401":{"description":"Not authenticated"},"403":{"description":"Access denied or visibility change attempted (immutable in v1)"},"404":{"description":"Bucket not found"},"409":{"description":"Bucket name conflict"}}},"delete":{"tags":["Buckets"],"summary":"Delete bucket","description":"Requires `confirm=true`. A folder that still holds files also requires `deleteContents=true`, and is refused with `bucket_not_empty` and a `file_count` otherwise. Both flags must come from the person, never defaulted by a client.","operationId":"deleteBucket","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"confirm","in":"query","required":false,"schema":{"type":"string"},"description":"Must be `true`. Acknowledges that deleting a folder is destructive."},{"name":"deleteContents","in":"query","required":false,"schema":{"type":"string"},"description":"Must be `true` to delete a folder that still holds files. Ignored when the folder is empty or already deleting."}],"responses":{"204":{"description":"Bucket deleted","headers":{"X-Deleted-Id":{"description":"UUID of the deleted bucket","schema":{"type":"string","format":"uuid"}}}},"400":{"description":"`bucket_delete_confirm_required` when `confirm=true` is missing, `bucket_not_empty` (with `file_count`) when the folder still holds files and `deleteContents=true` is missing, or a validation error."},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Bucket not found"},"409":{"description":"`bucket_landed_not_discardable`: a pending folder whose creation transaction already landed. It finishes through finalize or in the background instead."}}}},"/api/v1/buckets/{id}/policy":{"get":{"tags":["Buckets"],"summary":"Check a bucket's Seal policy before uploading","description":"Preflight for a private upload. Reports whether the policy this bucket's files are encrypted under can be resolved: the recorded policy must exist on Sui as a bucket group, and where the bucket records its creator it must be the group derived for the bucket. It does not check that the caller holds a role in the group, or that the group is not paused. The upload route applies the same rule: when the state is `missing` it answers 409 `bucket_policy_unresolvable`, or 404 for reason `policy_missing`, as every other route does. This route needs an API key to hold a read grant on the bucket's group, and an upload needs a write grant.","operationId":"getBucketPolicy","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"}],"responses":{"200":{"description":"Policy state","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["state","seal_policy_id"],"properties":{"state":{"type":"string","enum":["resolvable","pending","missing","unknown","not_required"],"description":"`resolvable`: the policy is in place, so encrypt and upload. The answer can be up to 10 minutes old. `pending`: the bucket is not finalized yet, so finish finalize and read again. `missing`: uploads are refused, see `reason`. `unknown`: Sui could not confirm the group yet, so read again; a node that did not answer is asked again after 30 seconds. Uploads are not refused on it. `not_required`: a public bucket, which has no policy."},"reason":{"type":"string","enum":["policy_missing","wrong_group","not_on_chain","not_a_bucket_group"],"description":"Present only when `state` is `missing`. `policy_missing`: no policy is recorded; every other route answers 404 for such a bucket. `wrong_group`: the recorded policy is not the group derived for the bucket. `not_on_chain`: no object exists on Sui at the policy id. `not_a_bucket_group`: an object exists there and it is not a bucket group."},"seal_policy_id":{"type":["string","null"],"description":"The policy (group) object id recorded for the bucket. Null for a public bucket, or when none is recorded."}}}}}}}},"400":{"description":"Invalid UUID"},"401":{"description":"Not authenticated"},"403":{"description":"Access denied, or `mirror_missing_grant`: the API key holds no read grant on the bucket's group, or its grant is still being indexed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Bucket not found"},"409":{"description":"The bucket is being deleted (`bucket_deleting`) or is gone (`bucket_gone`), as an upload into it would answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/buckets/{id}/files":{"get":{"tags":["Files"],"summary":"List files in bucket","operationId":"listBucketFiles","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous response's next_cursor"},{"name":"q","in":"query","schema":{"type":"string","maxLength":255},"description":"Case-insensitive substring match on file name. `%` and `_` match literally; they are not wildcards. The query is Unicode-normalised to NFC before it is matched, like the stored names, so the composed and the decomposed spelling of `café` find the same rows."},{"name":"sortField","in":"query","schema":{"type":"string","enum":["name","date","size","type"],"default":"date"}},{"name":"sortOrder","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}}],"responses":{"200":{"description":"File list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object"}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, null when no more results"}}}}}}}},"400":{"description":"Invalid path parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["Files"],"summary":"Upload file (async)","description":"A name already taken in the folder is not an error: the file is stored under the next free ` (n)` suffix before the extension, so a second `report.txt` is stored as `report (1).txt` and a third as `report (2).txt`. Taken is case-insensitive (`Report.TXT` collides with `report.txt`). The 202 returns the stored name in `name`, and the requested one in `renamed_from` when the suffix was applied. Use `id` or that `name`, not the name you sent, to find the file afterwards.","operationId":"uploadBucketFile","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"},"name":{"type":"string","maxLength":255,"description":"Stored Unicode-normalised to NFC, and the 255 limit is measured on that form. Names that differ only by normal form are the same name: uploading the composed and the decomposed spelling of `café.pdf` yields `café.pdf` and `café (1).pdf`, never two rows that look identical. A client that binds the name into a private file's AAD (it sends `contentType`) must send it here rather than rely on the `file` part's filename: that filename has `\"`, CR and LF escaped by the client and unescaped by the parser, and `%` is never escaped, so a name that literally contains `%22` is stored altered and that file could never be downloaded."},"metadata":{"type":"string","description":"Optional JSON object string persisted in the file's metadata column. Envelope limits: max 8KB, max depth 5, max 100 keys.  Two reserved keys — `description` and `tags` — are validated against the same limits as `PATCH /files/{id}`: description ≤ 1000 chars, tags ≤ 10 items, each tag ≤ 30 chars. Over-limit values return 400 with field-level detail.  Reserved keys are also normalized before persist (trimmed, tags deduped case-insensitively and stored canonical lowercase). Other keys pass through unchanged. A null character anywhere in the object returns 400, and so does a control character in a tag or, tabs and line breaks aside, in the description.","example":"{\"description\":\"trip photos\",\"tags\":[\"travel\",\"nature\"],\"client_state\":\"any\"}"},"contentSize":{"type":"string","description":"Plaintext byte count (non-negative integer, sent as a string) for private (Seal-encrypted) uploads, where the body is ciphertext. Must not exceed the uploaded payload size. Ignored for public buckets. Required whenever `contentType` is sent for a private bucket, because the size is bound into the ciphertext alongside the type: sending one without the other is refused with 400 rather than stored as a file that can never be downloaded.","example":"0"},"contentType":{"type":"string","description":"COMG-1059: the MIME type the client declares for the file, stored verbatim as `declared_mime_type` and bound into a private file's Seal AAD. Send it as its own form field: the `file` part's Content-Type header is the parser's to rewrite, and a client that binds one value while the server records another can never download that file. Required for any client that writes an AAD, and sending it for a private bucket also requires `contentSize`. Omitted, the server falls back to the part header and the file is treated as unbound."}}}}}},"responses":{"202":{"description":"Accepted. `name` is the name the file was stored under.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"allOf":[{"$ref":"#/components/schemas/FileSummary"},{"type":"object","required":["renamed_from"],"properties":{"renamed_from":{"type":["string","null"],"description":"The name the upload asked for, when it was taken in the folder and `name` is the suffixed one; null when the file was stored under the name it was sent with."}}}]}},"required":["data"]}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Bucket not found"},"409":{"description":"Bucket not finalized, duplicate file name in bucket, or `bucket_policy_unresolvable`: the private bucket's Seal policy cannot be resolved, so nothing uploaded to it could be decrypted. Read `GET /api/v1/buckets/{id}/policy` for the reason. Not retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Payload too large","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"415":{"description":"File type is not accepted (`code: \"unsupported_file_type\"`). The check reads the file name extension for every upload. Every upload also gets a signature check on the first 8 KiB. It detects executables only, and it reads nothing from ciphertext. The server derives the recorded `mime_type` from the name and does not check it against the content.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Quota exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limited"}}}},"/api/v1/buckets/{id}/finalize":{"post":{"tags":["Buckets"],"summary":"Finalize private bucket","description":"Submit the on-chain signature to finalize a pending private bucket. Transitions the bucket from 'pending' to 'active'.","operationId":"finalizeBucket","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["signature"],"properties":{"signature":{"type":"string","minLength":1}}}}}},"responses":{"200":{"description":"Bucket finalized","content":{"application/json":{"schema":{"type":"object","properties":{"bucket_id":{"type":"string","format":"uuid"},"seal_policy_id":{"type":["string","null"]},"provisioning_state":{"type":"string"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Bucket not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"`bucket_not_pending` (the folder is no longer pending, e.g. discarded), `bucket_finalize_conflict` (changed under this call; retry), or `bucket_group_claimed_elsewhere` (permanent: discard the folder and create it again)."},"410":{"description":"`digest_expired`: the signing window closed. POST /buckets/{id}/resume for fresh bytes."},"502":{"description":"`enoki_execute_failed`: nothing was created and the folder's slot is released. Create it again."},"503":{"description":"The folder may still complete: `enoki_execute_unconfirmed`, `chain_probe_unavailable` or `bucket_provision_pending`. Retry finalize with the same bucket id and signature after a short backoff; it settles the earlier attempt and returns the same folder."}}}},"/api/v1/seal/sponsor":{"post":{"tags":["SEAL"],"summary":"Sponsor a SEAL transaction","description":"Build and sponsor an on-chain bucket-policy PTB via Enoki. Returns transaction bytes for the client to sign, plus a digest the client must echo to the execute endpoint.","operationId":"sealSponsor","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["kind","groupIds","recipientAddress","scope"],"properties":{"kind":{"type":"string","enum":["grant_bucket_access"]},"groupIds":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},"recipientAddress":{"type":"string"},"scope":{"type":"string","enum":["read","readwrite"]}}},{"type":"object","required":["kind","groupIds","adminSignerAddress"],"description":"permissioned_group::grant_permission<ExtensionPermissionsAdmin> — back-fills a Key-Admin key's manager role across the space's existing private buckets. Sender must hold PermissionsAdmin on each group. Session-only.","properties":{"kind":{"type":"string","enum":["grant_admin_access"]},"groupIds":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},"adminSignerAddress":{"type":"string"}}},{"type":"object","required":["kind","bucketId"],"properties":{"kind":{"type":"string","enum":["bucket_group_create"]},"bucketId":{"type":"string","format":"uuid"}}},{"type":"object","required":["kind","groupId","member"],"description":"bucket_policy::add_admin — grants the manager role (BucketAdmin + ExtensionPermissionsAdmin). Admins manage viewers/editors and decrypt content, but cannot remove owners or pause the bucket. Sender must hold both PermissionsAdmin and ExtensionPermissionsAdmin. Session-only.","properties":{"kind":{"type":"string","enum":["share_admin"]},"groupId":{"type":"string"},"member":{"type":"string"}}},{"type":"object","required":["kind","groupId","member"],"description":"bucket_policy::revoke_all_permissions — removes every permission `member` holds on the group. The wire kind keeps the legacy `unshare` name. Aborts on-chain if `member` is the group's last owner (ECannotRemoveLastAdmin). Refused with 403 `nothing_to_revoke` before sponsoring when `member` holds no permission on the group, which can briefly include a grant that has only just landed. Session-only.","properties":{"kind":{"type":"string","enum":["unshare"]},"groupId":{"type":"string"},"member":{"type":"string"}}},{"type":"object","required":["kind","groupId","serviceSignerAddress"],"description":"bucket_policy::revoke_all_permissions targeted at an API key's service signer — submitted once per bucket by the revocation flow. Same on-chain guards as `unshare`, and the same 403 `nothing_to_revoke` when the signer holds no permission. Session-only: the revocation plan is authorized against the session user, so a Bearer caller has no legitimate use for this kind.","properties":{"kind":{"type":"string","enum":["unshare_bucket_access"]},"groupId":{"type":"string"},"serviceSignerAddress":{"type":"string"}}}]}}}},"responses":{"200":{"description":"Transaction bytes and digest","content":{"application/json":{"schema":{"type":"object","properties":{"bytes":{"type":"string"},"digest":{"type":"string"}}}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied or precheck failed"},"429":{"description":"Rate limited, always with `Retry-After`. Code `TooManyRequestsError` is the per-account/per-IP sponsor limiter (shared with /execute); code `sponsor_cap_exceeded` is the daily cap on sponsored transactions, per account or across all accounts. A limiter store outage also answers 429 (fail closed)."}}}},"/api/v1/seal/sponsor/{digest}/execute":{"post":{"tags":["SEAL"],"summary":"Execute a sponsored SEAL transaction","description":"Submit the wallet signature over the sponsored bytes. Enoki broadcasts the transaction and returns the on-chain digest.","operationId":"sealSponsorExecute","security":[{"bearerAuth":[]}],"parameters":[{"name":"digest","in":"path","required":true,"schema":{"type":"string","minLength":1},"description":"Sponsor digest returned by POST /seal/sponsor"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["signature"],"properties":{"signature":{"type":"string","minLength":1}}}}}},"responses":{"200":{"description":"On-chain transaction digest","content":{"application/json":{"schema":{"type":"object","properties":{"digest":{"type":"string"}}}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"Digest not found or expired"}}}},"/api/v1/seal/aggregator/v1/fetch_key":{"post":{"tags":["SEAL"],"summary":"Proxy Seal aggregator fetch_key on mainnet","description":"Backend proxy for `POST ${aggregatorUrl}/v1/fetch_key`.\n\nErrors: this endpoint is shaped to behave well with the Seal SDK, which keeps only the `message` field of an error body. Relayed aggregator errors keep the key-server `{error, message}` shape verbatim; locally generated failures add `message: \"console:<code>\"` to the console `{error, code}` envelope — the prefix cannot collide with Seal error names.","operationId":"sealFetchKey","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ptb","enc_key","enc_verification_key","request_signature","certificate"],"properties":{"ptb":{"type":"string","minLength":1},"enc_key":{"type":"string","minLength":1},"enc_verification_key":{"type":"string","minLength":1},"request_signature":{"type":"string","minLength":1},"certificate":{"type":"object","required":["user","session_vk","creation_time","ttl_min","signature"],"properties":{"user":{"type":"string","description":"Sui address that signed the SessionKey. Under cookie auth it must be the session wallet. Under Bearer auth either the API key's service signer or the key owner's wallet is accepted; a service key signs with the former."},"session_vk":{"type":"string","minLength":1},"creation_time":{"type":"integer","minimum":0},"ttl_min":{"type":"integer","minimum":1,"maximum":30},"signature":{"type":"string","minLength":1},"mvr_name":{"type":["string","null"]}}}}}}}},"responses":{"200":{"description":"Aggregator response bytes (JSON `{decryption_keys}`), returned verbatim.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"description":"Zod validation failure, `sessionkey_address_mismatch` when `certificate.user` matches neither the authenticated caller's wallet address nor the API key's service signer, or aggregator 400 (SDK-header fault) relayed verbatim."},"401":{"description":"Not authenticated"},"403":{"description":"Aggregator 403 relayed verbatim (`NoAccess`, `InvalidPTB`, ...), or `read_only_api_key` for a `key_admin` key. `read` and `readwrite` keys are both allowed here."},"413":{"description":"Request body exceeds 180 KiB (hono `bodyLimit`'s default; matches the Seal key server's own cap)."},"426":{"description":"Aggregator 426 relayed verbatim — client SDK is below the aggregator's floor (`DeprecatedSDKVersion`)."},"429":{"description":"Rate limit exceeded. Proxy GCRA denials (per-account 60/min, per-IP 120/min, global 200/s) always set `Retry-After`; a limiter-store outage also 429s (fail-closed via `DENIED_ON_STORE_ERROR`). An aggregator upstream 429 is relayed verbatim and its own `Retry-After` is preserved via `RELAYED_UPSTREAM_HEADERS`."},"502":{"description":"`SealAggregatorUpstreamError`: fetch failure, 8s timeout, oversized aggregator response body, a 3xx we refused to follow, or a 401 from the aggregator (`crates/key-server/src/errors.rs` cannot emit 401 itself — a 401 is Kong's `key-auth` rejecting our Enoki key; we remap so a global FE 401 handler does not log the user out on a proxy-side problem)."},"503":{"description":"Proxy disabled (`SEAL_PROXY_ENABLED=false`) or an aggregator 503 relayed verbatim."},"5XX":{"description":"Any other aggregator 5xx is relayed verbatim."}}}},"/api/v1/buckets/{id}/files/{fileId}":{"get":{"tags":["Files"],"summary":"Get file metadata","operationId":"getFileById","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FileSummary"}},"required":["data"]}}}},"400":{"description":"Invalid id"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden"},"404":{"description":"Not found (or file not in this bucket)"}}},"delete":{"tags":["Files"],"summary":"Delete file (async, soft-delete + worker)","description":"Marks the file as `deleting` and enqueues a background job that asks Oyster to remove the underlying blob. Returns 204 immediately; storage is released only after the worker confirms the Oyster delete. Subsequent retries are deduplicated by file id. Poll `GET /api/v1/buckets/{id}/files/{fileId}/deletion` for `deleted` to know when that has happened.","operationId":"deleteBucketFile","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"responses":{"204":{"description":"Accepted; file marked deleting"},"400":{"description":"Invalid path parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied"},"404":{"description":"File not found in this bucket"}}}},"/api/v1/buckets/{id}/files/{fileId}/status":{"get":{"tags":["Files"],"summary":"Get upload job status","description":"Poll the state of an in-flight upload job after the upload route returns 202. Maps the BullMQ job state to a public enum (queued, active, completed, failed). Returns 404 once the job has been removed from the queue per BullMQ retention limits — clients should rely on the file metadata route to confirm completion thereafter.","operationId":"getBucketFileUploadStatus","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"responses":{"200":{"description":"Current upload status","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["state"],"properties":{"state":{"type":"string","enum":["queued","active","completed","failed"]},"progress":{"type":"number","minimum":0,"maximum":1,"description":"Fractional progress (0..1). Always 1 when state is 'completed'. Absent when state is 'failed' (read 'error' instead), and absent while queued or active until the worker reports a positive value. Not guaranteed to only increase: a job retried after a failed attempt still carries that attempt's value until the retry reports its own."},"error":{"type":"object","description":"Structured sanitized error, present only when state is 'failed'.","required":["code","message"],"properties":{"code":{"type":"string","enum":["upload_payload_too_large","upload_bucket_not_found","upload_wallet_funding_required","upload_quota_rejected","upload_rate_limited","upload_unauthorized","upload_daily_funding_limit","upload_funding_paused","upload_funding_below_min_shortfall","upload_funding_failed","upload_funding_pending","upload_funding_gave_up","upload_funding_timeout","upload_cap_exceeded","upload_bucket_gone","upload_upstream_unavailable","upload_failed"],"description":"Machine-readable failure code. Each carries a retry class:\n`transient` (retry the same upload after a short pause), `daily_limit` (terminal for this account until `retry_at`; stop uploading to it before then), `funding_paused` (not this account's own limit: funding is paused for every account, or for new accounts as a group; stop, and retry at `retry_at` only when present), `storage_cap` (free space, then retry), `permanent` (retrying as-is will not succeed).\n\n- `upload_payload_too_large` (permanent): The file exceeds the maximum upload size.\n- `upload_bucket_not_found` (permanent): The bucket was not found when the worker picked the job up.\n- `upload_wallet_funding_required` (transient): The account's upload wallet was short and is being funded inline.\n- `upload_quota_rejected` (permanent): The storage service rejected the upload against the plan.\n- `upload_rate_limited` (transient): The upload service throttled the request.\n- `upload_unauthorized` (transient): The worker's authorization to the storage service failed.\n- `upload_daily_funding_limit` (daily_limit): This account's rolling 24h funding cap is reached; terminal for the account until the window rolls (see retry_at / retry_after_seconds).\n- `upload_funding_paused` (funding_paused): Upload funding is refused for a group of accounts, not by anything this account did: for every account (global daily cap or kill switch), or for new accounts only (the daily pool they share, or that pool switched off). retry_at is present when a cap window closed it and absent when a switch did.\n- `upload_funding_below_min_shortfall` (permanent): The funding the upload needed was below the minimum the service funds.\n- `upload_funding_failed` (transient): The funding service was unavailable.\n- `upload_funding_pending` (transient): Funding for this upload is still in flight.\n- `upload_funding_gave_up` (transient): Funding did not settle within the job's retry budget; re-upload the file.\n- `upload_funding_timeout` (transient): Funding settlement timed out.\n- `upload_cap_exceeded` (storage_cap): The space's storage cap is reached; terminal until files are deleted.\n- `upload_bucket_gone` (permanent): The bucket was deleted while the upload was in flight.\n- `upload_upstream_unavailable` (transient): The storage service was temporarily unavailable.\n- `upload_failed` (permanent): The upload failed for a reason the service does not classify; do not retry automatically."},"message":{"type":"string","description":"Human-readable sentence safe to show the user. For `upload_daily_funding_limit` it names the time uploads resume."},"http_status":{"type":"number","minimum":400,"maximum":599},"retry_at":{"type":"string","format":"date-time","description":"Expected instant the cap that refused this upload reopens (UTC, whole seconds). Present for `upload_daily_funding_limit`, and for `upload_funding_paused` when a cap window closed it (absent when a kill switch did). The cap can still be closed at that instant if later spend keeps it over the line; a new upload then fails with a later time."},"retry_after_seconds":{"type":"integer","minimum":0,"description":"Whole seconds from the moment of this response until `retry_at`, recomputed on every read; 0 once the window has rolled. Present exactly when `retry_at` is."}}}}}}}}}},"400":{"description":"Invalid path parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"File or upload job not found"}}}},"/api/v1/buckets/{id}/files/{fileId}/deletion":{"get":{"tags":["Files"],"summary":"Get file deletion status","description":"Poll a delete after `DELETE /api/v1/buckets/{id}/files/{fileId}` returns 204. `deleting` while the worker removes the blob, then `deleted`, which is terminal: the bucket and space storage counters are released in the same transaction that writes it. `delete_failed` is terminal too: the file was not deleted and still counts toward storage. Returns 404 for a file no delete has been requested for.","operationId":"getBucketFileDeletionStatus","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"responses":{"200":{"description":"Current deletion status","content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["state"],"properties":{"state":{"type":"string","enum":["deleting","deleted","delete_failed"]}}}}}}}},"400":{"description":"Invalid path parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"File not found in this bucket, or no delete has been requested for it"},"409":{"description":"The file's bucket is being deleted (`bucket_deleting`) or already deleted (`bucket_gone`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/buckets/{id}/files/{fileId}/download":{"get":{"tags":["Files"],"summary":"Download file","description":"Stream the raw file content. Served as `application/octet-stream` with `Content-Disposition: attachment` regardless of the uploader's MIME type, plus `nosniff`, a `sandbox` CSP and `X-Frame-Options: DENY` (SEC-491 F-1). Private/authenticated content is never cached by shared proxies.\n\nOnce user content is served from its own hostname (SEC-491 F-1 / COMG-819), this route stops returning bytes: a bearer caller gets a `307` to that host and **must follow redirects** (`curl -L`), and a session caller gets `403 use_download_url` and should mint via `POST .../download-url` instead. Until that hostname is configured the route streams as it does today.\n\nSession callers: this route stamps `last_opened_at`, which makes it a state change, so it requires an allow-listed `Origin` header (SEC-491 F-36). API-key callers are exempt.","operationId":"downloadBucketFile","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"responses":{"200":{"description":"File stream","headers":{"Content-Type":{"schema":{"type":"string"},"description":"Always `application/octet-stream` — the uploader's MIME is never echoed (SEC-491 F-1)"},"Content-Disposition":{"schema":{"type":"string"},"description":"attachment; filename=\"<encoded name>\""},"Content-Length":{"schema":{"type":"integer"},"description":"File size in bytes (omitted when unknown)"},"Cache-Control":{"schema":{"type":"string","example":"private, no-store"}}},"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Invalid path parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied, or a session request without an allow-listed `Origin` header (this route stamps `last_opened_at`, so it is not a safe method)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Bucket or file not found"},"423":{"description":"File row is in 'deleting' or 'deleted' status"},"500":{"description":"Blob stream unavailable"}}}},"/api/v1/buckets/{id}/files/{fileId}/download-url":{"post":{"tags":["Files"],"summary":"Mint a signed download URL","description":"Mint a short-lived HMAC-signed download URL (COMG-120 / ADR-013). The returned URL is redeemable at `GET /downloads/{token}` without authentication, until `expires_at`.\n\nThe requested TTL is clamped to the space plan's cap (free 15m, starter/pro 1h, business 24h). Mint is rate-limited per space AND per API key.\n\nMinting issues a bearer capability and spends rate-limit quota, so it is a state change and belongs on a method the anti-forgery check covers unconditionally (SEC-491 F-36). The `GET .../download?signed_url=true` form this replaced has been removed.\n\nThe body is optional: omit it to mint at the plan-default TTL.","operationId":"mintBucketFileDownloadUrl","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Bucket UUID"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"File UUID"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"ttl":{"type":"integer","minimum":1,"description":"Requested URL lifetime in seconds. Clamped to the space plan cap."}}}}}},"responses":{"200":{"description":"Signed-URL mint response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"download_url":{"type":"string","description":"Relative path of the form `/downloads/v1.<payload_b64url>.<sig_b64url>`","example":"/downloads/v1.eyJ2Ijox...mt.4eVH..."},"expires_at":{"type":"string","format":"date-time","description":"ISO 8601 expiry timestamp"}},"required":["download_url","expires_at"]}},"required":["data"]}}}},"400":{"description":"Invalid path parameter, or invalid `ttl` (non-positive or non-integer)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Access denied, or missing/invalid Origin on a session-authenticated request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Bucket or file not found"},"409":{"description":"File is not `active` yet — the asynchronous upload has not finished. Retry once `GET .../status` reports completion."},"415":{"description":"Request body declared a content type other than application/json. Sent rather than silently ignoring a `ttl` the server would not read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"423":{"description":"File row is in 'deleting' or 'deleted' status"},"429":{"description":"Mint rate limit exceeded for space or API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/search":{"get":{"tags":["Files"],"summary":"Global search across accessible files with filters + facets","operationId":"search","security":[{"bearerAuth":[]}],"parameters":[{"name":"cursor","in":"query","required":false,"schema":{"type":"string","minLength":1,"maxLength":512}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100}},{"name":"modifiedFrom","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"modifiedTo","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"q","in":"query","required":false,"description":"Search text, trimmed. At most 255 characters, the filename limit, so a file can be searched by its full name. A longer value returns 400 with `q must be 255 characters or fewer.`","schema":{"type":"string","maxLength":255}},{"name":"scope","in":"query","required":false,"schema":{"type":"string","enum":["space","bucket"]}},{"name":"scopeId","in":"query","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"sizeMax","in":"query","required":false,"schema":{"type":"integer","format":"int64","minimum":0}},{"name":"sizeMin","in":"query","required":false,"schema":{"type":"integer","format":"int64","minimum":0}},{"name":"sortDirection","in":"query","required":false,"schema":{"type":"string","enum":["asc","desc"]}},{"name":"sortField","in":"query","required":false,"schema":{"type":"string","enum":["modified","name","size"]}},{"name":"tags","in":"query","required":false,"schema":{"allOf":[{"type":"array","items":{"type":"string"}},{"type":"array","maxItems":20,"items":{"type":"string","minLength":1,"maxLength":64}}]}},{"name":"types","in":"query","required":false,"schema":{"allOf":[{"type":"array","items":{"type":"string"}},{"type":"array","maxItems":10,"items":{"type":"string","enum":["CODE","GEO","IMAGE","AUDIO","DOCUMENT","TABLE","TEXT","VIDEO","ARCHIVE","OTHER"]}}]}}],"responses":{"200":{"description":"Search results with facets + pagination","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"files":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"bucket_id":{"type":"string","format":"uuid"},"bucket_name":{"type":"string"},"name":{"type":"string"},"mime_type":{"type":["string","null"],"description":"Derived from the file name at upload and at rename, or from the client's declared type when the name maps to nothing. The server does not check it against the content. Every download route serves `application/octet-stream`."},"size":{"type":"string","description":"Byte size serialized as decimal string (BigInt)."},"status":{"type":"string","enum":["pending","uploading","active","deleting","failed","deleted"]},"metadata":{"type":"object"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"folders":{"type":"array","description":"Matching folders (Harbor buckets). Not paginated — the first page returns all matches up to `limit`; subsequent pages (`cursor`) return files only. `types` filter excludes folders (they aren't mime-typed). `scope=bucket` returns an empty list (the bucket itself is the search container).","items":{"type":"object","required":["id","space_id","name","metadata","storage_used","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"space_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"metadata":{"type":"object","additionalProperties":true},"storage_used":{"type":"string","description":"Aggregate storage of files in the folder, in bytes (bigint as string)."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"facets":{"type":"object","properties":{"categories":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","enum":["CODE","GEO","IMAGE","AUDIO","DOCUMENT","TABLE","TEXT","VIDEO","ARCHIVE","OTHER"]},"count":{"type":"integer"}}}}}},"total_count":{"type":"integer"}}},"pagination":{"type":"object","properties":{"limit":{"type":"integer"},"has_more":{"type":"boolean"},"next_cursor":{"type":["string","null"]}}}}}}}},"400":{"description":"Invalid query params or tampered cursor. An over-length `q` (over 255 characters) returns `{ error: 'q must be 255 characters or fewer.', code: 'bad_request' }`. A control character in `q` (tabs and line breaks aside) or in a `tags` value returns the same shape with a message naming the field."},"401":{"description":"Not authenticated."},"403":{"description":"Session or api-key not authorized for the requested scope."}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"API Key","description":"API key passed as Bearer token. Created via POST /api/v1/api-keys."}},"schemas":{"ErrorResponse":{"type":"object","properties":{"error":{"type":"string","description":"Human-readable error message"},"code":{"type":"string","description":"Machine-readable error code (when available)","enum":["USED_NONCE","EXPIRED_TIMESTAMP","ADDRESS_MISMATCH","INVALID_CHALLENGE","unauthorized","api_key_registering","api_key_revoking","api_key_revoked","api_key_rotation_incomplete","api_key_replaced","read_only_api_key","bucket_not_in_scope","mirror_missing_grant","bucket_not_finalized","bucket_deleting","bucket_gone","bucket_policy_unresolvable","file_deleting","quota_exceeded","payload_too_large","bad_request","email_already_registered","unsupported_media_type","unsupported_file_type","extension_change_not_allowed","invalid_origin","session_required","forbidden"]}},"required":["error"]},"RotationConflictResponse":{"type":"object","required":["error","code"],"description":"A rotation step refused because of the keys' state.","properties":{"error":{"type":"string","description":"Human-readable error message"},"code":{"type":"string","enum":["rotation_not_started","predecessor_not_revoked","api_key_already_replaced","rotation_scope_mismatch","rotation_space_mismatch","api_key_not_rotatable","api_key_not_a_successor"]}}},"RevocationConflictResponse":{"type":"object","required":["error","code"],"properties":{"error":{"type":"string","description":"Human-readable error message"},"code":{"type":"string","enum":["admin_transfer_required","service_signer_still_active","api_key_not_revocable"]},"buckets":{"type":"array","description":"Buckets whose Seal policy the caller does not administer, so the unshare cannot be signed. Sent only with `admin_transfer_required`.","items":{"type":"object","required":["bucket_id","seal_policy_id"],"properties":{"bucket_id":{"type":"string","format":"uuid"},"seal_policy_id":{"type":"string"}}}},"remaining_count":{"type":"integer","description":"How many buckets still list the service signer as active. Sent only with `service_signer_still_active`."}}},"PlanLimitExceededResponse":{"type":"object","required":["code","limit","current_tier"],"properties":{"code":{"type":"string","enum":["plan_limit_exceeded"]},"limit":{"type":"string","enum":["storage","users","buckets"]},"limit_value":{"type":"integer","description":"The cap that was hit, in the unit of `limit`: a folder count for `buckets`, a member count for `users`. Absent for `storage`, so clients must handle it being missing."},"current_tier":{"type":"string","enum":["free"]}}},"AddSpaceMemberRequest":{"type":"object","required":["userId"],"properties":{"userId":{"type":"string","format":"uuid"},"role":{"type":"string","enum":["admin","editor","viewer"],"default":"viewer"}}},"ChallengeResult":{"type":"object","properties":{"challenge":{"type":"string","description":"Structured challenge message to sign with wallet"},"nonce":{"type":"string","format":"uuid","description":"Server-generated single-use nonce"},"issuedAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","description":"Challenge expires 5 minutes after issuance"}},"required":["challenge","nonce","issuedAt","expiresAt"]},"AuthenticatedUser":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":["string","null"]},"suiAddress":{"type":"string"},"harborWalletAddress":{"type":["string","null"]},"authProvider":{"$ref":"#/components/schemas/AuthProvider"},"createdAt":{"type":"string","format":"date-time"}}},"UserProfileDTO":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":["string","null"]},"suiAddress":{"type":"string"},"harborWalletAddress":{"type":["string","null"]},"authProvider":{"$ref":"#/components/schemas/AuthProvider"},"personalSpaceId":{"type":["string","null"],"format":"uuid"},"createdAt":{"type":"string","format":"date-time"},"features":{"type":"object","properties":{"memories":{"type":"boolean"},"blobIdColumnVisible":{"type":"boolean"},"apiKeyExpirationInput":{"type":"boolean"},"apiKeyRotationResume":{"type":"boolean"}},"required":["memories","blobIdColumnVisible","apiKeyExpirationInput","apiKeyRotationResume"]}}},"AuthProvider":{"type":"string","enum":["google","apple","facebook","twitch","wallet"]},"LinkedAccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"provider":{"$ref":"#/components/schemas/AuthProvider"},"providerAddress":{"type":"string"},"linkedAt":{"type":"string","format":"date-time"}}},"ApiKeySummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"prefix":{"type":"string","description":"First 8 characters of the key for identification","example":"a1b2c3d4"},"space_id":{"type":"string","format":"uuid"},"permissions":{"type":"string","enum":["read_only","read_write","key_admin"],"description":"read_only: Can read objects in private buckets.\nread_write: Can read and write objects in private buckets.\nkey_admin: Can only create data-plane keys; no bucket access."},"last_used_at":{"type":["string","null"],"format":"date-time"},"rotation_started_at":{"type":["string","null"],"format":"date-time","description":"Set once a Rotate of this key has started."},"replaces_api_key_id":{"type":["string","null"],"format":"uuid","description":"On a Rotate's Successor: the key it replaces."},"rotation_ungranted":{"type":"boolean","description":"A Rotate's Successor minted more than five minutes ago that was never granted, as far as the indexer has seen. Its secrets were never shown; discard it with DELETE /api-keys/{id}/rotation and resume the Rotate."},"created_at":{"type":"string","format":"date-time"}}},"ApiKeyListItem":{"type":"object","description":"One row of GET /api-keys, as stored.","required":["id","space_id","user_id","prefix","scope","status","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"space_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"prefix":{"type":"string","description":"First 8 characters of the key for identification","example":"a1b2c3d4"},"scope":{"type":"string","enum":["read","readwrite","key_admin"],"description":"read: Can read objects in private buckets.\nreadwrite: Can read and write objects in private buckets.\nkey_admin: A Management API key; can only create data-plane keys."},"label":{"type":["string","null"]},"service_signer_address":{"type":"string"},"status":{"type":"string","enum":["active","registering","revoking","revoked"]},"expected_permission":{"type":["string","null"],"enum":["BucketViewer","BucketEditor",null]},"created_at":{"type":"string","format":"date-time"},"last_used":{"type":["string","null"],"format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"rotation_started_at":{"type":["string","null"],"format":"date-time","description":"Set once a Rotate of this key has started."},"replaces_api_key_id":{"type":["string","null"],"format":"uuid","description":"On a Rotate's Successor: the key it replaces."}}},"ApiKeyCreateResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":["string","null"]},"key":{"type":"string","description":"The full raw API key — shown only on creation, never retrievable again","example":"hbr_a1b2c3d4..."},"space_id":{"type":"string","format":"uuid"},"permissions":{"type":"string","enum":["read_only","read_write","key_admin"],"description":"read_only: Can read objects in private buckets.\nread_write: Can read and write objects in private buckets.\nkey_admin: Can only create data-plane keys; no bucket access."},"replaces_api_key_id":{"type":["string","null"],"format":"uuid","description":"The key this one replaces, when minted by a Rotate."},"created_at":{"type":"string","format":"date-time"}}},"SpaceListItem":{"type":"object","required":["id","type","name","storage_used","storage_cap","bucket_count","role","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["personal","team"]},"name":{"type":"string"},"storage_used":{"type":"integer","description":"Bytes currently stored"},"storage_cap":{"type":"integer","description":"Maximum bytes allowed"},"bucket_count":{"type":"integer"},"member_count":{"type":["integer","null"],"description":"Only present for team spaces"},"role":{"type":"string","enum":["owner","admin","editor","viewer"],"description":"owner for personal, team role for team spaces"},"created_at":{"type":"string","format":"date-time"}}},"BucketMetadata":{"type":"object","description":"COMG-489: folder Metadata tab projection (description, tags, stats).","required":["description","tags","size","asset_count","created_at","updated_at","last_opened_at"],"properties":{"description":{"type":["string","null"]},"tags":{"type":"array","items":{"type":"string"}},"size":{"type":"integer","description":"Total stored bytes across the folder's assets."},"asset_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"last_opened_at":{"type":["string","null"],"format":"date-time"}}},"Bucket":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"space_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"oyster_bucket_name":{"type":"string"},"visibility":{"type":"string","enum":["public","private"]},"seal_policy_id":{"type":["string","null"]},"creator":{"type":["string","null"],"description":"COMG-1059: the Sui address the folder's on-chain group was derived from. A client recomputes the group id from it and refuses a ciphertext whose Seal identity names a different group, instead of trusting `seal_policy_id`, which is a mutable column. Null for a public folder. Null on a private folder means the address was never recorded, which a client treats as a refusal rather than as permission to skip the check."},"provisioning_state":{"type":"string","enum":["pending_policy","active"],"description":"`pending_policy` is a folder still being created; it holds a plan slot but cannot hold files yet. `active` is a usable folder."},"chain_landed":{"type":"boolean","description":"COMG-1085: the folder's creation transaction landed. A `pending_policy` folder with this set exists on chain and only waits for storage; it finishes through finalize or in the background, and cannot be discarded."},"storage_used":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["id","space_id","name","oyster_bucket_name","visibility","seal_policy_id","storage_used","storage_cap","created_at","updated_at"]},"UpdateBucketRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":100,"description":"Stored Unicode-normalised to NFC, and the 100 limit is measured on that form. The composed and the decomposed spelling of `café` are one folder name, so the second is a 409 rather than an auto-suffixed `café (1)`. Case still tells two folders apart, unlike file names."},"visibility":{"type":"string","enum":["public","private"],"description":"Immutable in v1 — if provided, the request is rejected with 403."}},"required":["name"],"additionalProperties":false},"UpdateBucketResponse":{"type":"object","required":["id","name","visibility","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"visibility":{"type":"string","enum":["public","private"]},"updated_at":{"type":"string","format":"date-time"}}},"FileSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"bucket_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"blob_id":{"type":["string","null"]},"oyster_object_id":{"type":["string","null"]},"pooled_blob_object_id":{"type":["string","null"],"description":"Sui object id of the `PooledBlob` backing this file, resolvable on a Sui explorer. Null until the upload completes, for files uploaded before this field existed, and when Oyster did not store the blob on Walrus. Not unique: identical content in one space shares one `PooledBlob`."},"size":{"type":"integer"},"content_size":{"type":["integer","null"],"description":"Plaintext content size in bytes for private (encrypted) files; null for public files and legacy rows. The size field stays the stored (ciphertext) size."},"original_name":{"type":["string","null"],"description":"COMG-1059: the name this file was requested under, trimmed and NFC-normalised, before any collision suffix. Bound into a private file's Seal AAD, so a client rebuilds the binding from this value and refuses to decrypt if it disagrees. Immutable; a rename changes `name` and leaves this alone. Null on files uploaded before the binding shipped."},"declared_mime_type":{"type":["string","null"],"description":"COMG-1059: the MIME type the uploading client declared, verbatim, or an empty string when it declared none. Bound alongside `original_name`; distinct from `mime_type`, which the server derives from the name. Null on files uploaded before the binding shipped."},"mime_type":{"type":["string","null"],"description":"Derived from the file name at upload and at rename, or from the client's declared type when the name maps to nothing. The server does not check it against the content. Every download route serves `application/octet-stream`."},"status":{"type":"string"},"is_private":{"type":"boolean"},"metadata":{"type":"object","additionalProperties":true,"description":"User-defined metadata persisted by Walrus Console."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"deleted_at":{"type":["string","null"],"format":"date-time"},"last_opened_at":{"type":["string","null"],"format":"date-time"}},"required":["id","bucket_id","name","size","status","is_private","created_at","updated_at","last_opened_at"]}}}}