Two apps

App A holds the passkey. App B is on another origin and never sees it. Three things cross between them and none is secret: B's public key, the grant, and the sealed bytes.

What crosses and which way

  1. B makes a key with createGrantee() and sends A the publicKey.
  2. A calls s.grant() and sends B the transaction hash it returns as txHash.
  3. A puts the sealed bytes in a store B can read, and B fetches them.
app A, the owner
import { openOrEnrol, ns } from "satchel-sdk";

const registry = { address: "0xd6c895f5c9254dc6eaafc335fff0930fd7012e6d" };
const memory = ns("memory/assistant");
const s = await openOrEnrol({ rpId: "pip.example", appName: "Pip", registry, mode: "enrol" });   // mode matters on a first visit only

// What B sent: the publicKey from its createGrantee(). A public key, safe in a URL.
const publicKeyFromB = "0x04...";

// 1. The grant. One Monad transaction; its hash is everything B needs.
const grant = await s.grant(memory, publicKeyFromB, { label: "Fern" });
const link = `https://fern.example/connect?grant=${grant.txHash}`;   // or a QR code of the same string, or any message

// 2. The sealed bytes. Ciphertext, so any store that B can read from will do.
const blob = await s.seal(memory, { likes: "metric units" });
await fetch("https://pip.example/api/memories/1", { method: "PUT", body: blob.toBytes() });
app B, the reader
import { createGrantee, fetchGrant, ns, parseSealed } from "satchel-sdk";

const registry = { address: "0xd6c895f5c9254dc6eaafc335fff0930fd7012e6d" };
const b = await createGrantee();   // B's own key. b.publicKey is what B sent to A.

// (a) The grant, rebuilt from the transaction hash in the link.
const txHash = new URL(location.href).searchParams.get("grant") as `0x${string}`;
const grant = await fetchGrant({ txHash, registry, ns: ns("memory/assistant") });
const reader = b.reader(grant);

// (b) The sealed bytes, from wherever A put them.
const res = await fetch("https://pip.example/api/memories/1");
const bytes = new Uint8Array(await res.arrayBuffer());

parseSealed(bytes).epoch;          // 1, read from the header without a key
await reader.unsealJson(bytes);    // { likes: "metric units" }

The grant, by transaction hash

fetchGrant reads the Granted event out of that one transaction's receipt and returns a Grant B can use with its own key. It is a receipt lookup, never an eth_getLogs range scan, which Monad's public RPC caps at 100 blocks, so it works for a grant from any block. The hash can travel any way a short string can: a URL query, a QR code, a message, a row in A's backend. B checks nothing on trust. The wrapped key inside the event opens only with B's own private key, so a hash handed to the wrong app fails at reader() with GRANT_NOT_FOR_KEY, and a hash from another registry or a reverted transaction fails in fetchGrant with REGISTRY_UNREACHABLE.

The chain holds the namespace as a hash only. B passes ns when it knows the path, and fetchGrant checks it against the hash in the event (NAMESPACE_MISMATCH if they differ); without it the Grant has no ns and works the same.

app B, keeping its key
// B keeps its identity across visits: persist the key pair, pass it back in.
const stored = await idbGet("grantee");           // B's own IndexedDB
const b = await createGrantee(stored);            // omit stored on the first run
if (!stored) await idbPut("grantee", b.keyPair);  // a non-extractable CryptoKeyPair is storable

The sealed bytes, from any store

A sealed record is ciphertext with a plain header, so it can sit anywhere a public string of bytes can. A has three ordinary choices, and B reads the same bytes back from whichever it picked:

When B holds more than one grant, parseSealed(bytes).epoch says which grant to use for a record: the one issued at that epoch. A record from a later epoch than every grant B holds is the signal to ask A for a fresh grant (regrant) and to fetch it by its new hash.

What the demo does

Pip and Fern are two routes of one site, so they share one browser origin. Fern gets its grant the way this page describes: Pip's Sharing page returns Fern to /fern/?grant=<txHash> and Fern rebuilds the grant with fetchGrant; it keeps only that hash, in IndexedDB database fern, key grants (grant transaction hashes). The sealed memories take the one shortcut the shared origin allows: Fern reads the ciphertext Pip wrote to localStorage key satchel.pip.memories (sealed bytes Pip wrote). On two origins that line becomes a fetch from A's backend, IPFS or a URL, and nothing else changes.