Skip to main content

StarfishClient

Low-level HTTP client for the Starfish sync protocol. Handles cap-cert authentication, per-request Ed25519 signing, request formatting, and response parsing.

Prerequisites: Getting Started

Constructor

import {
StarfishClient,
bootstrapRootIdentity,
} from "@drakkar.software/starfish-client"

const creds = await bootstrapRootIdentity(passphrase)

const client = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: {
getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }),
},
})

StarfishClientOptions

interface StarfishClientOptions {
/** Base URL of the Starfish server (e.g. "https://api.example.com/v1") */
baseUrl: string
/**
* Cap-cert provider. When set, the client signs every outgoing request:
* each call carries `Authorization: Cap <base64(stableStringify(cap))>`
* plus `X-Starfish-Sig` / `X-Starfish-Ts` / `X-Starfish-Nonce` headers.
* Omit for unauthenticated public-read collections.
*/
capProvider?: StarfishCapProvider
/** Custom fetch implementation (defaults to global fetch) */
fetch?: typeof fetch
/**
* Optional offline-first read-through ciphertext cache.
* See [Offline & Connectivity](/state-offline/offline-connectivity).
*
* **Note:** persist-backed Zustand stores (`createStarfishStore` with a `storage`)
* are already offline-first for reads — a transport failure during `pull()` preserves
* the persisted data and sets `stale: true` without surfacing an error. You only need
* a client `cache` when you want ciphertext-at-rest storage between restarts or
* stale-while-revalidate on specific HTTP status codes (`cacheFallbackStatuses`).
*/
cache?: PullCache
/** Optional TTL for cache entries in ms. Omit for entries that never expire. */
cacheMaxAgeMs?: number
/**
* HTTP statuses that fall back to the cached snapshot instead of throwing
* (stale-while-revalidate). Recommended: `[429, 500, 502, 503, 504]`.
* See [Error Classification & Retry](/client-core/error-retry#stale-while-revalidate).
*/
cacheFallbackStatuses?: number[]
/**
* Called after a background revalidation succeeds following a
* `cacheFallbackStatuses` hit. Use to signal reachability to the host app.
*/
onRevalidated?: (path: string, result: PullResult) => void
}

The v2 auth: AuthProvider option (Bearer-token headers) was removed in 3.0. All authenticated requests now use the capProvider shape below; there is no Authorization: Bearer path. See the migration guide if you're coming from 2.x.

Cap Provider

capProvider.getCap() returns the device's cap-cert and its Ed25519 private key. The client uses the cap-cert as the Authorization: Cap … header value, and signs every request with the private key (request hash

  • ts + nonce → X-Starfish-Sig etc.).
interface StarfishCapProvider {
getCap(): Promise<{ cap: CapCert; devEdPrivHex: string }>
}

Implementations are expected to cache; the client may call getCap() once per authenticated request, so a typical implementation reads creds from local storage and returns the already-derived cap.

const capProvider: StarfishCapProvider = {
getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }),
}

For a deeper look at the cap-cert shape, scopes, and TTLs see 25. Capability Certs. For request-signing details see 03. SyncManager and packages/ts/protocol/src/request-signing.ts.

Methods

pull(path, checkpoint?)

Fetches synced data from the server. A regular collection always returns the full document — incremental ?checkpoint= sync is now an append-only-only feature (use pull(path, { appendField, since }) for append logs). The checkpoint number is still accepted for wire-compatibility but is ignored by regular collections.

const result = await client.pull(`/pull/users/${userId}/settings`)

Parameters:

ParameterTypeDescription
pathstringServer endpoint path
checkpointnumberOptional, accepted for back-compat. Ignored by regular collections (they always return the full document).

Returns: Promise<PullResult>

interface PullResult {
data: Record<string, unknown>
hash: string
timestamp: number
authorPubkey?: string
authorSignature?: string
}

Wire format:

GET {baseUrl}{path}?checkpoint={timestamp}
Authorization: Cap {base64(stableStringify(capCert))}
X-Starfish-Sig: {base64(ed25519 sig)}
X-Starfish-Ts: {unix-ms}
X-Starfish-Nonce: {base64(16 random bytes)}
Accept: application/json

Response 200:
{
"data": { ... },
"hash": "sha256-hex",
"timestamp": 1712345678
}

push(path, data, baseHash, authorSignature?)

Pushes data to the server. Uses optimistic concurrency — the server rejects the push if baseHash doesn't match the current server hash.

const success = await client.push(
`/push/users/${userId}/settings`,
{ theme: "dark", lang: "en" },
lastKnownHash, // null for first push
)

Parameters:

ParameterTypeDescription
pathstringServer endpoint path
dataRecord<string, unknown>The document to push
baseHashstring | nullHash of the last known server version. null for first push
authorSignaturestringOptional. Signature for data provenance

Returns: Promise<PushSuccess>

interface PushSuccess {
hash: string
timestamp: number
}

Wire format:

POST {baseUrl}{path}
Authorization: Cap {base64(stableStringify(capCert))}
X-Starfish-Sig: {base64(ed25519 sig)}
X-Starfish-Ts: {unix-ms}
X-Starfish-Nonce: {base64(16 random bytes)}
Content-Type: application/json

{
"data": { ... },
"baseHash": "sha256-hex-or-null"
}

Response 200: { "hash": "sha256-hex", "timestamp": 1712345678 }
Response 409: Conflict (baseHash mismatch)

Error Handling

ConflictError

Thrown when push() receives a 409 response (hash mismatch). This means another client pushed a change since your last pull.

import { ConflictError } from "@drakkar.software/starfish-client"

try {
await client.push(path, data, staleHash)
} catch (err) {
if (err instanceof ConflictError) {
// Pull latest, merge, retry
}
}

StarfishHttpError

Thrown for any non-OK HTTP response other than 409.

import { StarfishHttpError } from "@drakkar.software/starfish-client"

try {
await client.pull(path)
} catch (err) {
if (err instanceof StarfishHttpError) {
console.log(err.status) // e.g. 403
console.log(err.body) // server error message
}
}

Custom Fetch

Pass a custom fetch implementation for environments without a global fetch, or for adding interceptors:

const client = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider,
fetch: myCustomFetch,
})

Next Steps