Introduction
The Fula API provides S3-compatible endpoints for decentralized object storage. Built on IPFS with client-side encryption using HPKE and BLAKE3.
Base URL
All API requests should be made to:
https://your-gateway.example.com
Features
- S3 Compatible - Use existing S3 tools and SDKs
- Decentralized - Data stored across IPFS network
- Encrypted - Client-side encryption with HPKE
- Verifiable - BLAKE3 hashes and Bao streaming verification
# Using AWS CLI
aws --endpoint-url http://localhost:9000 \
s3 ls
# Using cURL
curl -X GET http://localhost:9000/ \
-H "Authorization: Bearer YOUR_TOKEN"
Authentication
All API requests require authentication using a Bearer token in the Authorization header.
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization |
Required | Bearer token: Bearer <token> |
Permissions
Tokens include permissions that control access:
read- Read objects and list bucketswrite- Create and modify objectsdelete- Delete objects and buckets
curl -X GET http://localhost:9000/my-bucket/file.txt \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>Invalid or missing token</Message>
<RequestId>abc123</RequestId>
</Error>
Error Handling
Errors are returned as XML responses following S3 conventions.
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
NoSuchBucket | 404 | Bucket does not exist |
NoSuchKey | 404 | Object does not exist |
BucketAlreadyExists | 409 | Bucket name taken |
AccessDenied | 403 | Permission denied |
InvalidArgument | 400 | Invalid request parameter |
InternalError | 500 | Server error |
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>NoSuchBucket</Code>
<Message>The specified bucket does not exist</Message>
<BucketName>my-bucket</BucketName>
<RequestId>4442587FB7D0A2F9</RequestId>
</Error>
Buckets
List Buckets
Returns a list of all buckets owned by the authenticated user. Each user has isolated bucket namespaces, so you will only see your own buckets.
Endpoint
GET /
Response
| Field | Type | Description |
|---|---|---|
Buckets | Array | List of bucket objects |
Bucket.Name | String | Bucket name |
Bucket.CreationDate | DateTime | ISO 8601 creation time |
curl -X GET http://localhost:9000/ \
-H "Authorization: Bearer $TOKEN"
<?xml version="1.0" encoding="UTF-8"?>
<ListAllMyBucketsResult>
<Owner>
<ID>user123</ID>
<DisplayName>John Doe</DisplayName>
</Owner>
<Buckets>
<Bucket>
<Name>my-documents</Name>
<CreationDate>2024-01-15T10:30:00Z</CreationDate>
</Bucket>
<Bucket>
<Name>backups</Name>
<CreationDate>2024-02-20T14:45:00Z</CreationDate>
</Bucket>
</Buckets>
</ListAllMyBucketsResult>
Create Bucket
Creates a new bucket with the specified name. Buckets are automatically isolated per user - each user has their own namespace, so multiple users can create buckets with the same name without conflicts.
Endpoint
PUT /{bucket}
Path Parameters
| Parameter | Required | Description |
|---|---|---|
bucket |
Required | Bucket name (3-63 chars, lowercase, no spaces) |
Per-User Isolation
Buckets are transparently namespaced by user ID. This means:
- User A's bucket "images" is completely separate from User B's "images"
- Users can only see and access their own buckets
- No bucket name conflicts between different users
Response Codes
200 OK- Bucket created successfully409 Conflict- Bucket already exists (for this user)
curl -X PUT http://localhost:9000/my-new-bucket \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
Location: /my-new-bucket
Content-Length: 0
Delete Bucket
Deletes an empty bucket. The bucket must be empty before deletion.
Endpoint
DELETE /{bucket}
Path Parameters
| Parameter | Required | Description |
|---|---|---|
bucket |
Required | Name of the bucket to delete |
curl -X DELETE http://localhost:9000/my-bucket \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
Head Bucket
Check if a bucket exists and you have permission to access it.
Endpoint
HEAD /{bucket}
curl -I http://localhost:9000/my-bucket \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
x-amz-bucket-region: us-east-1
Content-Length: 0
Objects
List Objects (v2)
Returns objects in a bucket. Supports pagination and prefix filtering.
Endpoint
GET /{bucket}?list-type=2
Query Parameters
| Parameter | Required | Description |
|---|---|---|
list-type | Optional | Use 2 for ListObjectsV2 |
prefix | Optional | Filter by key prefix |
delimiter | Optional | Group keys by delimiter |
max-keys | Optional | Max results (default: 1000) |
continuation-token | Optional | Pagination token |
curl -X GET "http://localhost:9000/my-bucket?list-type=2&prefix=docs/&max-keys=10" \
-H "Authorization: Bearer $TOKEN"
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<Name>my-bucket</Name>
<Prefix>docs/</Prefix>
<MaxKeys>10</MaxKeys>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>docs/readme.md</Key>
<LastModified>2024-03-15T10:30:00Z</LastModified>
<ETag>"d41d8cd98f00b204e9800998ecf8427e"</ETag>
<Size>1024</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>docs/api.md</Key>
<LastModified>2024-03-16T14:20:00Z</LastModified>
<ETag>"098f6bcd4621d373cade4e832627b4f6"</ETag>
<Size>2048</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
</ListBucketResult>
Get Object
Retrieves an object from a bucket. Supports range requests and conditional headers.
Endpoint
GET /{bucket}/{key}
Request Headers
| Header | Description |
|---|---|
Range | Byte range (e.g., bytes=0-1023) |
If-None-Match | Return 304 if ETag matches |
If-Modified-Since | Return 304 if not modified |
Response Headers
| Header | Description |
|---|---|
ETag | Object's MD5 hash |
Content-Length | Size in bytes |
Content-Type | MIME type |
Last-Modified | Last modification time |
x-amz-meta-* | Custom metadata |
# Full object
curl -X GET http://localhost:9000/my-bucket/file.txt \
-H "Authorization: Bearer $TOKEN" \
-o file.txt
# Range request (first 1KB)
curl -X GET http://localhost:9000/my-bucket/large-file.bin \
-H "Authorization: Bearer $TOKEN" \
-H "Range: bytes=0-1023"
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 1024
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Last-Modified: Sat, 15 Mar 2024 10:30:00 GMT
x-amz-meta-custom: my-value
[file contents]
Put Object
Uploads an object to a bucket. Objects are content-addressed using BLAKE3.
Endpoint
PUT /{bucket}/{key}
Request Headers
| Header | Description |
|---|---|
Content-Type | MIME type of the object |
Content-Length | Size in bytes |
Content-MD5 | Base64 MD5 for verification |
x-amz-meta-* | Custom metadata headers |
# Upload a file
curl -X PUT http://localhost:9000/my-bucket/document.pdf \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/pdf" \
-H "x-amz-meta-author: John Doe" \
--data-binary @document.pdf
# Upload text content
curl -X PUT http://localhost:9000/my-bucket/hello.txt \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/plain" \
-d "Hello, World!"
HTTP/1.1 200 OK
ETag: "098f6bcd4621d373cade4e832627b4f6"
x-amz-version-id: v1.0
Delete Object
Removes an object from a bucket.
Endpoint
DELETE /{bucket}/{key}
curl -X DELETE http://localhost:9000/my-bucket/old-file.txt \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
x-amz-delete-marker: true
Copy Object
Creates a copy of an object. Can copy within or across buckets.
Endpoint
PUT /{bucket}/{key}
Request Headers
| Header | Required | Description |
|---|---|---|
x-amz-copy-source |
Required | Source: /bucket/key |
curl -X PUT http://localhost:9000/backup-bucket/file-copy.txt \
-H "Authorization: Bearer $TOKEN" \
-H "x-amz-copy-source: /my-bucket/original-file.txt"
<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult>
<LastModified>2024-03-20T15:30:00Z</LastModified>
<ETag>"098f6bcd4621d373cade4e832627b4f6"</ETag>
</CopyObjectResult>
Head Object
Retrieves metadata without the object body.
Endpoint
HEAD /{bucket}/{key}
curl -I http://localhost:9000/my-bucket/file.txt \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 1024
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Last-Modified: Sat, 15 Mar 2024 10:30:00 GMT
x-amz-meta-author: John Doe
Multipart Upload
Upload large files in parts for improved reliability and performance.
Initiate Multipart Upload
Starts a multipart upload and returns an upload ID.
Endpoint
POST /{bucket}/{key}?uploads
curl -X POST "http://localhost:9000/my-bucket/large-file.zip?uploads" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/zip"
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>my-bucket</Bucket>
<Key>large-file.zip</Key>
<UploadId>VXBsb2FkIElE-abc123xyz</UploadId>
</InitiateMultipartUploadResult>
Upload Part
Uploads a part of a multipart upload. Parts must be at least 5MB (except last).
Endpoint
PUT /{bucket}/{key}?partNumber={n}&uploadId={id}
Query Parameters
| Parameter | Required | Description |
|---|---|---|
partNumber | Required | Part number (1-10000) |
uploadId | Required | Upload ID from initiate |
# Upload part 1
curl -X PUT "http://localhost:9000/my-bucket/large-file.zip?partNumber=1&uploadId=VXBsb2FkIElE-abc123xyz" \
-H "Authorization: Bearer $TOKEN" \
--data-binary @part1.bin
HTTP/1.1 200 OK
ETag: "a54357aff0632cce46d942af68356b38"
Complete Multipart Upload
Completes a multipart upload by assembling uploaded parts.
Endpoint
POST /{bucket}/{key}?uploadId={id}
curl -X POST "http://localhost:9000/my-bucket/large-file.zip?uploadId=VXBsb2FkIElE-abc123xyz" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/xml" \
-d '<CompleteMultipartUpload>
<Part><PartNumber>1</PartNumber><ETag>"a54357aff0632cce46d942af68356b38"</ETag></Part>
<Part><PartNumber>2</PartNumber><ETag>"0dc9f8eb2b9d9a6d8f8c7e5b4a3c2d1e"</ETag></Part>
</CompleteMultipartUpload>'
<?xml version="1.0" encoding="UTF-8"?>
<CompleteMultipartUploadResult>
<Location>http://localhost:9000/my-bucket/large-file.zip</Location>
<Bucket>my-bucket</Bucket>
<Key>large-file.zip</Key>
<ETag>"17fbc0a106abbb6f381aac6e331f2a19-2"</ETag>
</CompleteMultipartUploadResult>
Abort Multipart Upload
Aborts a multipart upload and frees associated resources.
Endpoint
DELETE /{bucket}/{key}?uploadId={id}
curl -X DELETE "http://localhost:9000/my-bucket/large-file.zip?uploadId=VXBsb2FkIElE-abc123xyz" \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
Object Tagging
Add key-value metadata tags to objects for categorization and lifecycle management.
Get Object Tagging
Returns the tag set associated with an object.
Endpoint
GET /{bucket}/{key}?tagging
curl -X GET "http://localhost:9000/my-bucket/file.txt?tagging" \
-H "Authorization: Bearer $TOKEN"
<?xml version="1.0" encoding="UTF-8"?>
<Tagging>
<TagSet>
<Tag>
<Key>environment</Key>
<Value>production</Value>
</Tag>
<Tag>
<Key>project</Key>
<Value>website</Value>
</Tag>
</TagSet>
</Tagging>
Put Object Tagging
Sets tags on an object. Replaces all existing tags.
Endpoint
PUT /{bucket}/{key}?tagging
Limits
- Maximum 10 tags per object
- Key: 1-128 Unicode characters
- Value: 0-256 Unicode characters
curl -X PUT "http://localhost:9000/my-bucket/file.txt?tagging" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/xml" \
-d '<Tagging>
<TagSet>
<Tag><Key>status</Key><Value>approved</Value></Tag>
<Tag><Key>owner</Key><Value>alice</Value></Tag>
</TagSet>
</Tagging>'
HTTP/1.1 200 OK
Content-Length: 0
Delete Object Tagging
Removes all tags from an object.
Endpoint
DELETE /{bucket}/{key}?tagging
curl -X DELETE "http://localhost:9000/my-bucket/file.txt?tagging" \
-H "Authorization: Bearer $TOKEN"
HTTP/1.1 204 No Content
🔐 Client-Side Encryption Headers
When using client-side encryption, Fula stores encryption metadata in custom headers.
Encryption Metadata Header
The x-amz-meta-encryption header contains JSON with encryption info:
Fields (content format v4, HPKE envelope v2)
- version — Content AEAD format.
4means the ciphertext is bound with AADfula:v4:content:{storage_key}(single-object) orfula:v4:chunk:{storage_key}:{index}(chunked). Legacy value2means no content AAD. - algorithm — Content cipher (
AES-256-GCMdefault,ChaCha20-Poly1305also supported). - nonce — Base64-encoded 12-byte nonce for the single-object path (absent for chunked; per-chunk nonces live inside
chunked.chunk_nonces). - wrapped_key — HPKE envelope (RFC 9180). Contains its own
version(2= RFC 9180 HPKE),encapsulated_key.ephemeral_public(32-byte X25519),cipher(chacha20poly1305), andciphertext(48 bytes = 32-byte DEK + 16-byte tag). DEK-wrap AAD isfula:v2:dek-wrap. - kek_version — Which KEK rotation generation unwraps this DEK.
- chunked — Present for files > 768 KB.
format: "streaming-v2"for AAD-bound chunks,chunk_size,num_chunks,total_size,root_hash(BLAKE3/Bao hex), andchunk_noncesarray. - metadata_privacy — Whether
private_metadatais included. - private_metadata —
EncryptedPrivateMetadata(original filename, size, content-type, user metadata, content_hash) encrypted under the DEK.
{
"version": 4,
"algorithm": "AES-256-GCM",
"nonce": "<base64, 12 bytes>",
"wrapped_key": {
"version": 2,
"encapsulated_key": { "ephemeral_public": "<base64, 32 bytes>" },
"cipher": "chacha20poly1305",
"ciphertext": "<base64, 48 bytes (32-byte DEK + 16-byte tag)>"
},
"kek_version": 1,
"metadata_privacy": true,
"private_metadata": "<base64 encrypted PrivateMetadata>"
}
{
"version": 4,
"algorithm": "AES-256-GCM",
"wrapped_key": { "version": 2, "encapsulated_key": {"ephemeral_public": "..."}, "cipher": "chacha20poly1305", "ciphertext": "..." },
"kek_version": 1,
"chunked": {
"format": "streaming-v2",
"chunk_size": 262144,
"num_chunks": 10,
"total_size": 2621440,
"root_hash": "<hex, 32-byte Bao root>",
"chunk_nonces": ["<base64>", "<base64>", "..."]
}
}
# Get metadata without downloading content
curl -I "http://localhost:9000/my-bucket/Qm0bb7a3f40dcd6057bd5abd04d2aa80f68680ea56a978" \
-H "Authorization: Bearer $TOKEN"
# Response headers include:
# x-amz-meta-x-fula-encrypted: true
# x-amz-meta-x-fula-encryption: {"version":4,"algorithm":"AES-256-GCM",...}
# x-amz-meta-x-fula-chunked: true (if chunked)
# Content-Type: application/octet-stream
# Content-Length: 156821 (ciphertext size)
🔒 Metadata Privacy
With metadata privacy enabled, the server never sees your real filenames, sizes, or content types.
What Gets Obfuscated
| Field | Server Sees | Client Decrypts |
|---|---|---|
| Key (filename) | e/a7c3f9b2e8d14a6f |
/finances/tax_2024.pdf |
| Size | 156,821 (ciphertext) | 156,789 (original) |
| Content-Type | application/octet-stream |
application/pdf |
| Timestamps | Upload time only | Original created/modified |
Private Metadata Structure
The private_metadata field, when decrypted, contains:
{
"original_key": "/finances/tax_returns_2024.pdf",
"actual_size": 156789,
"content_type": "application/pdf",
"created_at": 1701388800,
"modified_at": 1701475200,
"user_metadata": {
"author": "John Doe",
"department": "Accounting"
},
"content_hash": "blake3_hash_of_plaintext"
}
# DeterministicHash (default)
# Same path → same storage key (allows retrieval)
/photos/beach.jpg → e/a7c3f9b2e8d14a6f
# RandomUuid
# Each upload gets random key (maximum privacy)
/photos/beach.jpg → e/550e8400-e29b-41d4-a716-446655440000
# PreserveStructure
# Keep folder paths, hash only filenames
/photos/beach.jpg → /photos/e_a7c3f9b2e8d1
🤝 Secure Sharing API
Share encrypted files without exposing your master key. Uses HPKE to re-encrypt the DEK for each recipient.
Share Token Structure
A share token is a JSON object containing (see crates/fula-crypto/src/sharing.rs::ShareToken):
- id — 16-byte hex identifier.
- version — Share token envelope version (
SHARE_TOKEN_AAD_V5as of v0.3.x; bumped when the AAD binding shape changes). - path_scope — Scope the share grants access to.
is_valid_for_path(p)accepts anypthat starts withpath_scope. For FxFiles-style single-file shares this is the file'sstorage_key(CID). - wrapped_key — HPKE-wrapped DEK for the recipient (same envelope shape as the storage metadata's
wrapped_key; DEK-wrap AAD here is domain-separated by the token body so any mutation of other fields invalidates the unwrap). - permissions —
can_read,can_write,can_deleteflags. - mode —
Temporal(resolves to the latest version under the path, the default) orSnapshot(locked tosnapshot_binding). - snapshot_binding (optional, only in Snapshot mode) — BLAKE3 content hash, size, modified_at, and storage_key captured at share time.
- nonce (optional) — 12-byte content nonce for single-object shares. Present since `1b82b95` so the recipient can decrypt without fetching S3 metadata headers.
- chunked_metadata (optional) — serialized
ChunkedFileMetadataJSON for chunked files. Present since `1b82b95`. - encryption_version (optional,
u8) — the content AEAD version (e.g.4) the recipient should decrypt with. Stamped by fula-flutter since commit `9069817`; if absent, the recipient tries v4-AAD first and falls back to v2 (no AAD) for legacy tokens. - created_at — Unix seconds.
- expires_at — Unix seconds (optional;
None= never expires).
Permissions builder helpers
ShareBuilder::read_only()— Can decrypt and read files withinpath_scope.ShareBuilder::read_write()— Can also upload underpath_scope.ShareBuilder::full_access()— Read, write, and delete.
{
"id": "a137662366191e7456ff20213f2ff012",
"version": 5,
"path_scope": "Qmf6de2bfec2be33a7effd5e791ceed78ff5375e586bd3",
"wrapped_key": {
"version": 2,
"encapsulated_key": { "ephemeral_public": "<base64, 32 bytes>" },
"cipher": "chacha20poly1305",
"ciphertext": "<base64>"
},
"permissions": { "can_read": true, "can_write": false, "can_delete": false },
"mode": "Temporal",
"snapshot_binding": null,
"nonce": "<base64, 12 bytes>",
"chunked_metadata": null,
"encryption_version": 4,
"created_at": 1777500000,
"expires_at": 1778104800
}
┌─────────────────────────────────────────────────────────────────┐
│ SECURE SHARING FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. OWNER creates share: │
│ • Specify path scope: /photos/vacation/ │
│ • Set expiry: 7 days │
│ • Set permissions: read-only │
│ • Re-encrypt DEK for recipient's public key │
│ │
│ 2. OWNER sends token to RECIPIENT: │
│ • Via email, message, QR code, etc. │
│ • Owner's private key never leaves device │
│ │
│ 3. RECIPIENT accepts share: │
│ • Verify token signature │
│ • Check expiry │
│ • Decrypt DEK with own private key │
│ │
│ 4. RECIPIENT accesses files: │
│ • Fetch encrypted files from server │
│ • Decrypt with the shared DEK │
│ • Path checked against scope │
│ │
└─────────────────────────────────────────────────────────────────┘
Master-Independent Reads (v0.4.0+)
v0.4.0 introduced a coordinated server + SDK story so non-blox clients can read their own files even when the master gateway is offline. The SDK-side surfaces (gateway race, block cache, cold-start resolver, transparency types) live in SDK Examples; this section documents the HTTP-level additions on the master server itself.
What's new on the master
- Optional PUT header
x-amz-meta-fula-bucket-lookup-hthe SDK attaches on Phase 2 manifest-root commits (Phase 1.2). - Background users-index publisher that pins per-user + global CBORs to ipfs-cluster, publishes to IPNS every 5 min, and exposes the latest state via an internal endpoint (Phase 3.2).
- Bearer-protected internal admin endpoints at
/_internal/*(master) and/admin/users-index-anchor/trigger(mainnet-rewards-server) so operators can force a publish / chain-submit on demand instead of waiting up to 12 hours.
Every new server-side path is gated by an env flag default OFF; old fula-clients see byte-identical behavior to pre-v0.4.0 builds.
v0.4.1 — SDK-side fixes only; master is unchanged. v0.4.1 closes correctness gaps that surfaced when validating v0.4.0's offline-reads end-to-end against a live master: encrypted offline DOWNLOAD (single-object + chunked >768 KB) now works via warm-cache hits and gateway-served chunks, the offline-failure classifier was widened to detect real connection-refused/DNS/network-unreachable errors, and v7 sharded-HAMT manifest pages + dir-index now route through the offline-fallback wrapper so master-down LIST works for sharded buckets. Server-side endpoints, env flags, and request shapes are unchanged. See CHANGELOG for details.
Phase 1.2 — x-amz-meta-fula-bucket-lookup-h
Optional user-metadata header that the encrypted SDK attaches on the Phase 2 manifest-root PUT.
Carries a 16-byte client-derived blinded bucket lookup key (BLAKE3 of MetadataKey || bucket_name),
so the published global users-index CBOR can key its bucket entries without leaking plaintext bucket names to anyone who fetches it.
Format
x-amz-meta-fula-bucket-lookup-h: <32 hex chars>
32 lowercase hex chars (16 bytes). Master-side handler at fula-cli/src/handlers/object.rs calls BucketManager::populate_bucket_lookup_h after the flush.
Behavior
- Replace-on-change — the bucket-level field is set on first PUT and updated whenever an authenticated owner’s subsequent PUT carries a different value (e.g. reinstall or multi-device key rotation). Idempotent on identical input (no registry-persist churn).
- Non-fatal — a malformed or missing header is logged at
warn!level; the PUT response is unchanged. - Env-gated — master ignores the header unless
FULA_BUCKET_LOOKUP_H_ENABLED=1. - Backward-compat — old clients (no header) work unchanged. Buckets created without the header are emitted in the published CBOR with
legacy: trueand a plaintext-name key, so cold-start can still find them.
Phase 3.2 — GET /_internal/users-index-state
Returns the master's current published users-index state. Consumed by the chain-anchor cron in mainnet-rewards-server; rarely useful to apps directly.
Auth
Authorization: Bearer <FULA_USERS_INDEX_INTERNAL_TOKEN> — generated by the operator setup script and shared between master + cron + (optionally) pinning-webui.
Response codes
- 200 — success; body documented below.
- 401 — bearer missing or wrong (constant-time compared).
- 503 — fail-closed: publisher disabled (
FULA_USERS_INDEX_PUBLISHER_ENABLEDunset) OR token unset.
Response body (JSON)
{
"cid": "bafyrei...", // CID of the latest pinned global users-index CBOR (or null on pre-first-tick)
"sequence": 17, // monotonic sequence inside the CBOR payload
"updated_at_unix": 1714780000, // wall-clock timestamp of last commit
"ipns_key_name": "fula-users-index"
}
Operator — POST /_internal/publish-now
Triggers an immediate publisher tick instead of waiting up to FULA_USERS_INDEX_FLUSH_INTERVAL_SECS (default 5 min). Useful during deploy verification.
Auth
Same bearer token as /_internal/users-index-state. Same 401 / 503 fail-closed semantics.
Response (200) body
{
"global_cid": "bafyrei...",
"sequence": 18,
"changed_users": 1, // users whose per-user CBOR was newly pinned
"failed_users": 0, // per-user pins that failed (tick continues; failed users retry next tick)
"total_users": 6,
"global_rebuilt": true
}
Operator UI
The pinning-webui admin section (/admin/fula) ships a "Publish now" button that proxies to this endpoint.
Reuses the operator's session cookie for the inbound auth and the bearer token for the outbound call.
Operator — POST /admin/users-index-anchor/trigger
On the mainnet-rewards-server (not the master gateway). Triggers an immediate users-index chain-anchor submission instead of waiting up to 12 h for the periodic cron.
Auth
Reuses FULA_USERS_INDEX_INTERNAL_TOKEN via Authorization: Bearer .... Constant-time compare.
Response codes
- 200 — tick committed; per-network results in body.
- 401 — wrong/missing bearer.
- 409 — another tick is already in flight (cron OR a prior HTTP trigger). Retry after a moment.
- 503 — fail-closed: anchor service disabled (
FULA_USERS_INDEX_ANCHOR_ENABLEDunset) OR token unset.
Response (200) body
{
"committed": true,
"masterCid": "bafyrei...",
"masterSequence": "18",
"networks": [
{ "network": "base", "status": "fulfilled", "submitted": true },
{ "network": "skale", "status": "fulfilled", "submitted": false }
]
}
Concurrency
An in-flight flag inside runTick prevents two simultaneous ticks from racing the on-chain latest() reads + publish() calls (which would cause one tx to revert with NonMonotonicSequence). HTTP triggers that contend with the periodic cron get a clean 409.