Namespace
A validated path. The brand makes a raw string a type error where a Namespace is expected.
type Namespace = string & { readonly __brand: "satchel.ns" }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.
A namespace is a path. A session is one passkey ceremony.
A validated path. The brand makes a raw string a type error where a Namespace is expected.
type Namespace = string & { readonly __brand: "satchel.ns" }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).
function ns(path: string): Namespacepath stringReturns. The same string, branded as a Namespace.
Throws. INVALID_NAMESPACE
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.
function nsHashOf(n: Namespace): HexReturns. A 32 byte hash as 0x-hex.
Throws. no SatchelError.
Options shared by open(), enrol() and openOrEnrol().
interface OpenOptions {
rpId: string;
credential?: PasskeyCredentialMetadata;
store?: Store;
registry?: Registry;
webAuthnClient?: WebAuthnClient;
recover?: boolean;
}rpId stringcredential PasskeyCredentialMetadatastore Storeregistry RegistrywebAuthnClient WebAuthnClientrecover booleanOpens a vault with an existing passkey. One passkey prompt.
function open(o: OpenOptions): Promise<Satchel>Returns. A Satchel holding the root in memory until close().
Throws. PRF_UNAVAILABLERPID_MISMATCHPASSKEY_CANCELLEDNO_SECOND_PASSKEYWRONG_PASSKEYSTORE_UNAVAILABLE
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).
function enrol(o: OpenOptions & { appName: string }): Promise<Satchel>appName stringReturns. A Satchel.
What a first visit does in openOrEnrol: open a passkey the person already has, or create one.
type PasskeyMode = "open" | "enrol"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.
function openOrEnrol(o: OpenOptions & { appName: string; mode?: PasskeyMode }): Promise<Satchel>appName stringmode PasskeyModeReturns. 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
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.
function rememberedPasskey(o: { rpId: string; store?: Store }): Promise<PasskeyCredentialMetadata | undefined>rpId stringstore StoreReturns. Mera's credential metadata, or undefined.
Throws. STORE_UNAVAILABLE
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().
function fromPrfOutput(prf: Uint8Array, o: OpenOptions): Promise<Satchel>prf Uint8ArrayReturns. A Satchel.
Throws. PRF_UNAVAILABLE
What you do once a passkey has opened the vault. Methods are anchored as satchel-<name>.
An open vault. Key material is derived on demand from the root and zeroed after use; close() zeroes the root.
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 | undefinedregistryOwner AddressEncrypts 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.
seal(n: Namespace, data: Uint8Array | string | object): Promise<Sealed>Returns. A Sealed that is safe to store anywhere.
Decrypts a blob. The header's namespace hash is compared with the one asked for before any decryption.
unseal(n: Namespace, blob: Sealed | Uint8Array): Promise<Uint8Array>Returns. The plaintext bytes.
Throws. SESSION_CLOSEDNOT_SEALEDSEAL_CORRUPTNAMESPACE_MISMATCHWRONG_PASSKEY
unseal() followed by JSON.parse. A payload that is not JSON throws the platform SyntaxError, not a SatchelError.
unsealJson<T = unknown>(n: Namespace, blob: Sealed | Uint8Array): Promise<T>Returns. The parsed value.
Throws. SESSION_CLOSEDNOT_SEALEDSEAL_CORRUPTNAMESPACE_MISMATCHWRONG_PASSKEY
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.
fingerprint(n: Namespace): Promise<string>Returns. For example 4cda9f4b.
Throws. SESSION_CLOSED
Creates a second passkey and wraps the root to it in the store, so either passkey opens every namespace. One more prompt.
addPasskey(o: { appName: string }): Promise<void>Throws. SESSION_CLOSEDPRF_UNAVAILABLERPID_MISMATCHPASSKEY_CANCELLEDSTORE_UNAVAILABLE
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.
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
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.
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
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.
revoke(n: Namespace, grantId: string): Promise<{ epoch: number; txHash: Hex }>Returns. The new epoch and the transaction hash.
Zeroes the root in memory. Every later call throws SESSION_CLOSED.
close(): voidThrows. no SatchelError.
Four plain header fields, then ciphertext: SAT1, version, namespace hash, epoch, IV.
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.
interface Sealed {
readonly v: 1;
readonly nsHash: Hex;
readonly epoch: number;
toBytes(): Uint8Array;
}nsHash Hexepoch numberReads the header of stored bytes without a key. Use it to route a blob to the right namespace or to show its epoch.
function parseSealed(b: Uint8Array): SealedReturns. A Sealed.
Throws. NOT_SEALEDSEAL_CORRUPT
The app on the other side of a grant holds its own key and never imports the passkey path.
A P-256 public key, 65 bytes uncompressed, 0x-hex. Safe to send over any channel.
type GranteePublicKey = HexMakes the reading app's identity. Pass keyPair to keep it across page loads: a non-extractable CryptoKeyPair can live in IndexedDB.
function createGrantee(keyPair?: CryptoKeyPair): Promise<{
publicKey: GranteePublicKey;
keyPair: CryptoKeyPair;
reader(g: Grant | FetchedGrant): Reader;
}>keyPair CryptoKeyPairReturns. 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.
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.
interface Grant {
id: string;
ns: Namespace;
nsHash: Hex;
epoch: number;
granteeKeyHash: Hex;
wrappedKey: Hex;
label: string;
txHash: Hex;
}wrappedKey Hexepoch numberA 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.
type FetchedGrant = Omit<Grant, "ns"> & { ns?: Namespace }What fetchGrant() needs to find one grant.
interface FetchGrantOptions {
txHash: Hex;
registry: Address | Registry;
rpcUrl?: string;
chain?: Chain;
ns?: Namespace;
}txHash Hexregistry Address | RegistryrpcUrl stringns NamespaceReads 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.
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.
Opens records through one grant. Obtained from createGrantee().reader(grant).
interface Reader {
unseal(blob: Sealed | Uint8Array): Promise<Uint8Array>;
unsealJson<T = unknown>(blob: Sealed | Uint8Array): Promise<T>;
}Decrypts a record with the grant's epoch key. A record from a later epoch fails with GRANT_REVOKED; one from an earlier epoch with GRANT_EPOCH_OLDER.
unseal(blob: Sealed | Uint8Array): Promise<Uint8Array>Returns. The plaintext bytes.
Throws. NOT_SEALEDSEAL_CORRUPTNAMESPACE_MISMATCHGRANT_REVOKEDGRANT_EPOCH_OLDERGRANT_NOT_FOR_KEY
unseal() followed by JSON.parse.
unsealJson<T = unknown>(blob: Sealed | Uint8Array): Promise<T>Returns. The parsed value.
Throws. NOT_SEALEDSEAL_CORRUPTNAMESPACE_MISMATCHGRANT_REVOKEDGRANT_EPOCH_OLDERGRANT_NOT_FOR_KEY
The epoch registry. Defaults to Monad testnet (chain 10143); pass chain and rpcUrl for any other EVM chain.
interface Registry {
address: Address;
rpcUrl?: string;
chain?: Chain;
}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.
function epochOf(r: Registry, owner: Address, nsHash: Hex): Promise<number>owner AddressnsHash HexReturns. The epoch, at least 1.
Throws. REGISTRY_UNREACHABLE
The registry's events (Granted, Revoked, Rotated) and functions (epochOf, grant, revoke, rotate, grantOf), typed for viem. Use it to read the registry directly.
const registryAbi: Abi // parseAbi of the registry's events and functionsHolds 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.
interface Store {
get(key: string): Promise<Uint8Array | undefined>;
put(key: string, value: Uint8Array): Promise<void>;
delete?(key: string): Promise<void>;
}Every failure carries a code, a fix and a docs path.
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.
class SatchelError extends Error {
readonly code: ErrorCode;
readonly fix: string;
readonly docs: string;
}Narrows a caught value to SatchelError, so its code, fix and docs are typed.
function isSatchelError(e: unknown): e is SatchelErrorReturns. true when e is a SatchelError.
Throws. no SatchelError.
The closed set of codes. The table of every code with its cause and fix is generated from the SDK build.
type ErrorCode = "PRF_UNAVAILABLE" | "PASSKEY_CANCELLED" | ...