API reference

Everything exported from satchel-sdk: the functions that open a vault, one session interface, the grant functions and the types around them. Each function lists the error codes it can throw, linked to the error table.

Two facts the signatures do not show. The namespace path is not on chain: the registry, the Granted event and every sealed header carry only its hash, nsHashOf(path). And revoking one grant moves the namespace to the next epoch for every reader, so each other reader needs regrant before it opens new records. Grants and epochs has the rule.

Namespaces and sessions

A namespace is a path. A session is one passkey ceremony.

Namespace

type

A validated path. The brand makes a raw string a type error where a Namespace is expected.

signature
type Namespace = string & { readonly __brand: "satchel.ns" }

ns

function

Validates a path once. The path is the HKDF info string (prefixed with satchel/ns/) and the unit of a grant. The path itself is not on chain: the registry and every sealed blob carry only its hash, nsHashOf(path).

signature
function ns(path: string): Namespace
path string
3 to 64 characters matching /^[a-z0-9]+([/-][a-z0-9]+)*$/, for example memory/assistant.

Returns. The same string, branded as a Namespace.

Throws. INVALID_NAMESPACE

nsHashOf

function

keccak256 of satchel/ns/ plus the path. This hash, never the path, is what the registry stores, what Granted events carry and what a sealed header holds, so a namespace name stays private to the apps that know it.

signature
function nsHashOf(n: Namespace): Hex

Returns. A 32 byte hash as 0x-hex.

Throws. no SatchelError.

OpenOptions

interface

Options shared by open(), enrol() and openOrEnrol().

signature
interface OpenOptions {
  rpId: string;
  credential?: PasskeyCredentialMetadata;
  store?: Store;
  registry?: Registry;
  webAuthnClient?: WebAuthnClient;
  recover?: boolean;
}
rpId string
Must equal the rpId the passkey was created under, usually location.hostname of your production origin.
credential PasskeyCredentialMetadata
Mera's credential metadata. Omit to let the browser offer a passkey.
store Store
Where wrapped roots for added passkeys live. Default: IndexedDB.
registry Registry
The epoch registry. Without it every namespace stays at epoch 1 and grant and revoke are unavailable.
webAuthnClient WebAuthnClient
Runs the WebAuthn ceremonies. Default: the browser's navigator.credentials.
recover boolean
Demand that the chosen passkey is one added with addPasskey(). Throws NO_SECOND_PASSKEY if it is not.

enrol

function

Creates a passkey with PRF and opens a vault with it. One prompt, two on authenticators that skip PRF at creation (Mera does the second ceremony).

signature
function enrol(o: OpenOptions & { appName: string }): Promise<Satchel>
appName string
Shown on the passkey sheet, for example Satchel vault for Pip.

Returns. A Satchel.

Throws. PRF_UNAVAILABLERPID_MISMATCHPASSKEY_CANCELLED

PasskeyMode

type

What a first visit does in openOrEnrol: open a passkey the person already has, or create one.

signature
type PasskeyMode = "open" | "enrol"

openOrEnrol

function

One call for a first and a later visit, and one WebAuthn prompt per call: a browser grants a click one prompt, and a get followed by a create can be refused or leave a duplicate passkey. Decision rule, in order. (1) A credential in the options: open() with it. (2) A marker in the store from an earlier openOrEnrol for this rpId: open() with that credential and ignore mode. If that prompt is closed or its passkey is gone (deleted, made on another device or authenticator), the marker is deleted and PASSKEY_NOT_FOUND is thrown, so a stale marker never traps anyone and nothing is created. (3) No marker and mode "open": open() lets the browser offer any passkey for this rpId, so a synced passkey from another device opens here; a closed prompt or no passkey is PASSKEY_NOT_FOUND. (4) No marker and mode "enrol": enrol() creates one. (5) No marker and no valid mode: CHOOSE_PASSKEY_MODE, before any prompt. A successful (3) or (4) writes the marker. Any other error from the ceremony is thrown as it is. In a page, ask the person on a first visit (rememberedPasskey() says whether it is one) with two buttons, "Create a passkey" calling with mode "enrol" and "I already have one" calling with mode "open", each its own click.

signature
function openOrEnrol(o: OpenOptions & { appName: string; mode?: PasskeyMode }): Promise<Satchel>
appName string
Shown on the passkey sheet when a passkey is created.
mode PasskeyMode
Read only when no passkey is remembered. "enrol" creates a passkey, "open" uses one the person already has. Omitting it on a first visit throws CHOOSE_PASSKEY_MODE.

Returns. A Satchel. The marker holds the public credential id only and lives in the store (default IndexedDB); a runtime with no store skips it and the mode decides every call.

Throws. PRF_UNAVAILABLERPID_MISMATCHPASSKEY_CANCELLEDPASSKEY_NOT_FOUNDCHOOSE_PASSKEY_MODENO_SECOND_PASSKEYWRONG_PASSKEYSTORE_UNAVAILABLE

rememberedPasskey

function

The credential openOrEnrol remembers for this rpId, or undefined on a first visit. Read it before any prompt to choose between one button ("Run") and two ("Create a passkey", "I already have one"). It starts no WebAuthn ceremony.

signature
function rememberedPasskey(o: { rpId: string; store?: Store }): Promise<PasskeyCredentialMetadata | undefined>
rpId string
The rpId the marker was written under.
store Store
Default: IndexedDB.

Returns. Mera's credential metadata, or undefined.

Throws. STORE_UNAVAILABLE

fromPrfOutput

function

Builds a session from a 32 byte PRF output you already hold, for tests and for server-side readers of a vault the passkey opened elsewhere. The session takes ownership of the array and zeroes it on close().

signature
function fromPrfOutput(prf: Uint8Array, o: OpenOptions): Promise<Satchel>
prf Uint8Array
Exactly 32 bytes.

Returns. A Satchel.

Throws. PRF_UNAVAILABLE

The Satchel session

What you do once a passkey has opened the vault. Methods are anchored as satchel-<name>.

Satchel

interface

An open vault. Key material is derived on demand from the root and zeroed after use; close() zeroes the root.

signature
interface Satchel {
  readonly credential: PasskeyCredentialMetadata | undefined;
  readonly registryOwner: Address;
  seal(n: Namespace, data: Uint8Array | string | object): Promise<Sealed>;
  unseal(n: Namespace, blob: Sealed | Uint8Array): Promise<Uint8Array>;
  unsealJson<T = unknown>(n: Namespace, blob: Sealed | Uint8Array): Promise<T>;
  fingerprint(n: Namespace): Promise<string>;
  addPasskey(o: { appName: string }): Promise<void>;
  grant(n: Namespace, to: GranteePublicKey, o: { label: string }): Promise<Grant>;
  regrant(n: Namespace, to: GranteePublicKey, o: { label: string }): Promise<Grant>;
  revoke(n: Namespace, grantId: string): Promise<{ epoch: number; txHash: Hex }>;
  close(): void;
}
credential PasskeyCredentialMetadata | undefined
The passkey that opened this session.
registryOwner Address
The EOA that owns this vault's namespaces in the registry. Fund it to pay for grant and revoke.

Satchel.seal

method

Encrypts under the namespace's current epoch key. Objects are JSON encoded. With a registry configured it reads the current epoch from chain first; without one the epoch is 1.

method
seal(n: Namespace, data: Uint8Array | string | object): Promise<Sealed>

Returns. A Sealed that is safe to store anywhere.

Throws. SESSION_CLOSEDREGISTRY_UNREACHABLE

Satchel.fingerprint

method

Eight hex characters of SHA-256 over a fixed label and the namespace key. Safe to display, and equal on every device that holds the same passkey, which makes it the cross-device check.

method
fingerprint(n: Namespace): Promise<string>

Returns. For example 4cda9f4b.

Throws. SESSION_CLOSED

Satchel.grant

method

Wraps the namespace's current epoch key to the grantee's public key with HPKE and records the grant in the registry. Before sending it reads the owner's balance and the gas estimate, and throws OWNER_UNFUNDED, naming the owner address and the shortfall, if the owner cannot pay. Resolves after the transaction has a successful receipt. The registry owner address pays gas.

method
grant(n: Namespace, to: GranteePublicKey, o: { label: string }): Promise<Grant>

Returns. The Grant, including the wrapped key and the transaction hash. The txHash is all another app needs to rebuild it with fetchGrant().

Throws. SESSION_CLOSEDINVALID_GRANTEE_KEYOWNER_UNFUNDEDREGISTRY_UNREACHABLE

Satchel.regrant

method

Issues a grant at the namespace's current epoch. Call it after revoke(): revoking any one grant moves the namespace to the next epoch for every reader, so each remaining reader's earlier grant opens only the records sealed before the revoke, and needs a fresh grant to read new ones. regrant is grant() under the name that says why it is being called, and it records one more grant in the registry.

method
regrant(n: Namespace, to: GranteePublicKey, o: { label: string }): Promise<Grant>

Returns. The new Grant at the current epoch.

Throws. SESSION_CLOSEDINVALID_GRANTEE_KEYOWNER_UNFUNDEDREGISTRY_UNREACHABLE

Satchel.revoke

method

Deactivates the grant and moves the namespace to the next epoch. The epoch belongs to the namespace, not to the grant, so every reader's grant stops opening records sealed from then on: the revoked app for good, every other reader until it is granted again with regrant(). Records sealed before the revoke stay readable under the grants of their epoch. Resolves after a successful receipt; throws OWNER_UNFUNDED before sending if the owner cannot pay.

method
revoke(n: Namespace, grantId: string): Promise<{ epoch: number; txHash: Hex }>

Returns. The new epoch and the transaction hash.

Throws. SESSION_CLOSEDOWNER_UNFUNDEDREGISTRY_UNREACHABLE

Satchel.close

method

Zeroes the root in memory. Every later call throws SESSION_CLOSED.

method
close(): void

Throws. no SatchelError.

Sealed blobs

Four plain header fields, then ciphertext: SAT1, version, namespace hash, epoch, IV.

Sealed

interface

A sealed record. It carries its namespace hash and epoch in the clear so a mismatch is a named error before any decryption. The namespace path itself is never in the bytes.

signature
interface Sealed {
  readonly v: 1;
  readonly nsHash: Hex;
  readonly epoch: number;
  toBytes(): Uint8Array;
}
nsHash Hex
keccak256 of satchel/ns/ plus the path.
epoch number
The epoch key this record was sealed under.

parseSealed

function

Reads the header of stored bytes without a key. Use it to route a blob to the right namespace or to show its epoch.

signature
function parseSealed(b: Uint8Array): Sealed

Returns. A Sealed.

Throws. NOT_SEALEDSEAL_CORRUPT

Grants and readers

The app on the other side of a grant holds its own key and never imports the passkey path.

GranteePublicKey

type

A P-256 public key, 65 bytes uncompressed, 0x-hex. Safe to send over any channel.

signature
type GranteePublicKey = Hex

createGrantee

function

Makes the reading app's identity. Pass keyPair to keep it across page loads: a non-extractable CryptoKeyPair can live in IndexedDB.

signature
function createGrantee(keyPair?: CryptoKeyPair): Promise<{
  publicKey: GranteePublicKey;
  keyPair: CryptoKeyPair;
  reader(g: Grant | FetchedGrant): Reader;
}>
keyPair CryptoKeyPair
An ECDH P-256 pair from an earlier call. Omit to generate a fresh, non-extractable one.

Returns. The public key to hand to the owner, the key pair to persist, and reader(g) to open records through a grant.

Throws. no SatchelError.

Grant

interface

What the owner hands the reader. Every field except ns is also on chain in the registry's Granted event. The namespace path is not: the chain holds only nsHash, so ns is the path the owner or reader supplied, checked against that hash.

signature
interface Grant {
  id: string;
  ns: Namespace;
  nsHash: Hex;
  epoch: number;
  granteeKeyHash: Hex;
  wrappedKey: Hex;
  label: string;
  txHash: Hex;
}
wrappedKey Hex
HPKE (RFC 9180, DHKEM P-256, HKDF-SHA256, AES-128-GCM) seal of the epoch key to the grantee: 65 byte encapsulated key then 48 byte ciphertext.
epoch number
The one epoch this grant opens.

FetchedGrant

type

A Grant read back from chain. The namespace path was never on chain, only its hash, so ns is present only when the caller named the namespace to fetchGrant(). createGrantee().reader() accepts it exactly as it accepts a Grant.

signature
type FetchedGrant = Omit<Grant, "ns"> & { ns?: Namespace }

FetchGrantOptions

interface

What fetchGrant() needs to find one grant.

signature
interface FetchGrantOptions {
  txHash: Hex;
  registry: Address | Registry;
  rpcUrl?: string;
  chain?: Chain;
  ns?: Namespace;
}
txHash Hex
The hash grant() returned as txHash.
registry Address | Registry
The registry the grant was sent to.
rpcUrl string
Overrides the registry's RPC for this read.
ns Namespace
The namespace the reader expects. Compared with the hash in the event, and returned as the Grant's ns.

fetchGrant

function

Reads the Granted event from one transaction's receipt and returns the Grant a reader can open with its own key. One receipt lookup, never an eth_getLogs range scan, which Monad's public RPC caps at 100 blocks. The wrapped key in the event is HPKE-sealed to the grantee's public key, so another key's reader gets GRANT_NOT_FOR_KEY. This is how a grant crosses from one app to another: the owner hands over the transaction hash by URL, QR code, message or backend.

signature
function fetchGrant(o: FetchGrantOptions & { ns: Namespace }): Promise<Grant>
function fetchGrant(o: FetchGrantOptions): Promise<FetchedGrant>

Returns. The Grant. With ns given it is a Grant; without, a FetchedGrant.

Throws. NAMESPACE_MISMATCHREGISTRY_UNREACHABLE

Reader

interface

Opens records through one grant. Obtained from createGrantee().reader(grant).

signature
interface Reader {
  unseal(blob: Sealed | Uint8Array): Promise<Uint8Array>;
  unsealJson<T = unknown>(blob: Sealed | Uint8Array): Promise<T>;
}

Registry

interface

The epoch registry. Defaults to Monad testnet (chain 10143); pass chain and rpcUrl for any other EVM chain.

signature
interface Registry {
  address: Address;
  rpcUrl?: string;
  chain?: Chain;
}

epochOf

function

Reads a namespace's current epoch from the registry. A namespace that was never revoked reads as 1. Use it to check whether an earlier grant or revoke landed.

signature
function epochOf(r: Registry, owner: Address, nsHash: Hex): Promise<number>
owner Address
The registry owner, session.registryOwner.
nsHash Hex
nsHashOf(namespace).

Returns. The epoch, at least 1.

Throws. REGISTRY_UNREACHABLE

registryAbi

constant

The registry's events (Granted, Revoked, Rotated) and functions (epochOf, grant, revoke, rotate, grantOf), typed for viem. Use it to read the registry directly.

signature
const registryAbi: Abi  // parseAbi of the registry's events and functions

Store

interface

Holds ciphertext and public records only: wrapped roots for added passkeys and the public passkey marker. Never a key in the clear. The default is IndexedDB. delete is optional: a store without it has a stale marker overwritten with an empty value, which reads as absent.

signature
interface Store {
  get(key: string): Promise<Uint8Array | undefined>;
  put(key: string, value: Uint8Array): Promise<void>;
  delete?(key: string): Promise<void>;
}

Errors

Every failure carries a code, a fix and a docs path.

SatchelError

class

The only error type the SDK throws for its own failures. message states the cause, fix states the next step, docs is the path of the matching entry in the error table.

signature
class SatchelError extends Error {
  readonly code: ErrorCode;
  readonly fix: string;
  readonly docs: string;
}

isSatchelError

function

Narrows a caught value to SatchelError, so its code, fix and docs are typed.

signature
function isSatchelError(e: unknown): e is SatchelError

Returns. true when e is a SatchelError.

Throws. no SatchelError.

ErrorCode

type

The closed set of codes. The table of every code with its cause and fix is generated from the SDK build.

signature
type ErrorCode = "PRF_UNAVAILABLE" | "PASSKEY_CANCELLED" | ...