Fula API

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
cURL
# 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

HeaderRequiredDescription
Authorization Required Bearer token: Bearer <token>

Permissions

Tokens include permissions that control access:

  • read - Read objects and list buckets
  • write - Create and modify objects
  • delete - Delete objects and buckets
Request
curl -X GET http://localhost:9000/my-bucket/file.txt \
    -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
401 Unauthorized
<?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

CodeHTTP StatusDescription
NoSuchBucket404Bucket does not exist
NoSuchKey404Object does not exist
BucketAlreadyExists409Bucket name taken
AccessDenied403Permission denied
InvalidArgument400Invalid request parameter
InternalError500Server error
Error Response
<?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

GET

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

FieldTypeDescription
BucketsArrayList of bucket objects
Bucket.NameStringBucket name
Bucket.CreationDateDateTimeISO 8601 creation time
Request
curl -X GET http://localhost:9000/ \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
<?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>
PUT

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

ParameterRequiredDescription
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 successfully
  • 409 Conflict - Bucket already exists (for this user)
Request
curl -X PUT http://localhost:9000/my-new-bucket \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
HTTP/1.1 200 OK
Location: /my-new-bucket
Content-Length: 0
DELETE

Delete Bucket

Deletes an empty bucket. The bucket must be empty before deletion.

Endpoint

DELETE /{bucket}

Path Parameters

ParameterRequiredDescription
bucket Required Name of the bucket to delete
Request
curl -X DELETE http://localhost:9000/my-bucket \
    -H "Authorization: Bearer $TOKEN"
Response 204 No Content
HTTP/1.1 204 No Content
HEAD

Head Bucket

Check if a bucket exists and you have permission to access it.

Endpoint

HEAD /{bucket}
Request
curl -I http://localhost:9000/my-bucket \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
HTTP/1.1 200 OK
x-amz-bucket-region: us-east-1
Content-Length: 0

Objects

GET

List Objects (v2)

Returns objects in a bucket. Supports pagination and prefix filtering.

Endpoint

GET /{bucket}?list-type=2

Query Parameters

ParameterRequiredDescription
list-typeOptionalUse 2 for ListObjectsV2
prefixOptionalFilter by key prefix
delimiterOptionalGroup keys by delimiter
max-keysOptionalMax results (default: 1000)
continuation-tokenOptionalPagination token
Request
curl -X GET "http://localhost:9000/my-bucket?list-type=2&prefix=docs/&max-keys=10" \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
<?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

Get Object

Retrieves an object from a bucket. Supports range requests and conditional headers.

Endpoint

GET /{bucket}/{key}

Request Headers

HeaderDescription
RangeByte range (e.g., bytes=0-1023)
If-None-MatchReturn 304 if ETag matches
If-Modified-SinceReturn 304 if not modified

Response Headers

HeaderDescription
ETagObject's MD5 hash
Content-LengthSize in bytes
Content-TypeMIME type
Last-ModifiedLast modification time
x-amz-meta-*Custom metadata
Request
# 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"
Response 200 OK
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

Put Object

Uploads an object to a bucket. Objects are content-addressed using BLAKE3.

Endpoint

PUT /{bucket}/{key}

Request Headers

HeaderDescription
Content-TypeMIME type of the object
Content-LengthSize in bytes
Content-MD5Base64 MD5 for verification
x-amz-meta-*Custom metadata headers
Request
# 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!"
Response 200 OK
HTTP/1.1 200 OK
ETag: "098f6bcd4621d373cade4e832627b4f6"
x-amz-version-id: v1.0
DELETE

Delete Object

Removes an object from a bucket.

Endpoint

DELETE /{bucket}/{key}
Request
curl -X DELETE http://localhost:9000/my-bucket/old-file.txt \
    -H "Authorization: Bearer $TOKEN"
Response 204 No Content
HTTP/1.1 204 No Content
x-amz-delete-marker: true
PUT

Copy Object

Creates a copy of an object. Can copy within or across buckets.

Endpoint

PUT /{bucket}/{key}

Request Headers

HeaderRequiredDescription
x-amz-copy-source Required Source: /bucket/key
Request
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"
Response 200 OK
<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult>
    <LastModified>2024-03-20T15:30:00Z</LastModified>
    <ETag>"098f6bcd4621d373cade4e832627b4f6"</ETag>
</CopyObjectResult>
HEAD

Head Object

Retrieves metadata without the object body.

Endpoint

HEAD /{bucket}/{key}
Request
curl -I http://localhost:9000/my-bucket/file.txt \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
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.

POST

Initiate Multipart Upload

Starts a multipart upload and returns an upload ID.

Endpoint

POST /{bucket}/{key}?uploads
Request
curl -X POST "http://localhost:9000/my-bucket/large-file.zip?uploads" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/zip"
Response 200 OK
<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
    <Bucket>my-bucket</Bucket>
    <Key>large-file.zip</Key>
    <UploadId>VXBsb2FkIElE-abc123xyz</UploadId>
</InitiateMultipartUploadResult>
PUT

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

ParameterRequiredDescription
partNumberRequiredPart number (1-10000)
uploadIdRequiredUpload ID from initiate
Request
# 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
Response 200 OK
HTTP/1.1 200 OK
ETag: "a54357aff0632cce46d942af68356b38"
POST

Complete Multipart Upload

Completes a multipart upload by assembling uploaded parts.

Endpoint

POST /{bucket}/{key}?uploadId={id}
Request
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>'
Response 200 OK
<?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>
DELETE

Abort Multipart Upload

Aborts a multipart upload and frees associated resources.

Endpoint

DELETE /{bucket}/{key}?uploadId={id}
Request
curl -X DELETE "http://localhost:9000/my-bucket/large-file.zip?uploadId=VXBsb2FkIElE-abc123xyz" \
    -H "Authorization: Bearer $TOKEN"
Response 204 No Content
HTTP/1.1 204 No Content

Object Tagging

Add key-value metadata tags to objects for categorization and lifecycle management.

GET

Get Object Tagging

Returns the tag set associated with an object.

Endpoint

GET /{bucket}/{key}?tagging
Request
curl -X GET "http://localhost:9000/my-bucket/file.txt?tagging" \
    -H "Authorization: Bearer $TOKEN"
Response 200 OK
<?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

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
Request
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>'
Response 200 OK
HTTP/1.1 200 OK
Content-Length: 0
DELETE

Delete Object Tagging

Removes all tags from an object.

Endpoint

DELETE /{bucket}/{key}?tagging
Request
curl -X DELETE "http://localhost:9000/my-bucket/file.txt?tagging" \
    -H "Authorization: Bearer $TOKEN"
Response 204 No Content
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. 4 means the ciphertext is bound with AAD fula:v4:content:{storage_key} (single-object) or fula:v4:chunk:{storage_key}:{index} (chunked). Legacy value 2 means no content AAD.
  • algorithm — Content cipher (AES-256-GCM default, ChaCha20-Poly1305 also 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), and ciphertext (48 bytes = 32-byte DEK + 16-byte tag). DEK-wrap AAD is fula: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), and chunk_nonces array.
  • metadata_privacy — Whether private_metadata is included.
  • private_metadataEncryptedPrivateMetadata (original filename, size, content-type, user metadata, content_hash) encrypted under the DEK.
Encryption Metadata Structure (single-object, v4)
{
  "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>"
}
Encryption Metadata Structure (chunked, streaming-v2)
{
  "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>", "..."]
  }
}
Head Request to Get Metadata
# 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:

Decrypted Private Metadata
{
  "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"
}
Key Obfuscation Modes
# 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_V5 as 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 any p that starts with path_scope. For FxFiles-style single-file shares this is the file's storage_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).
  • permissionscan_read, can_write, can_delete flags.
  • modeTemporal (resolves to the latest version under the path, the default) or Snapshot (locked to snapshot_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 ChunkedFileMetadata JSON 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 within path_scope.
  • ShareBuilder::read_write() — Can also upload under path_scope.
  • ShareBuilder::full_access() — Read, write, and delete.
Share Token JSON Structure (post-v0.3.0)
{
  "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
}
Sharing Workflow
┌─────────────────────────────────────────────────────────────────┐
│                     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-h the 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: true and 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_ENABLED unset) 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_ENABLED unset) 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.