Storage interface
Everything the SDK persists goes through a Storage implementation you provide.
IndexedDbStorage is the browser default, MemoryStorage is for tests, and the interface
is small enough to port to new backends (Tauri filesystem, React Native, a server-backed
sync service) with a few dozen lines.
The interface #
interface Storage {
// Config — account roster and default DID.
loadConfig(): Promise<Config>;
saveConfig(config: Config): Promise<void>;
// Identity — X25519 + Ed25519 keypairs, per DID.
loadIdentity(did: string): Promise<Identity>;
saveIdentity(did: string, identity: Identity): Promise<void>;
// Session — OAuth or legacy tokens, DPoP keys, per DID.
loadSession(did: string): Promise<Session>;
saveSession(did: string, session: Session): Promise<void>;
clearSession(did: string): Promise<void>;
// Full account removal (identity + session + cache + config entry).
removeAccount(did: string): Promise<void>;
// Ephemeral pair-state: raw bytes of the X25519 private half during
// pairing. WASM writes on createPairRequest, reads on tryCompletePair,
// deletes on success or cancel. Never crosses back into JS.
savePairState(did: string, rkey: string, privateKey: Uint8Array): Promise<void>;
loadPairState(did: string, rkey: string): Promise<Uint8Array>;
deletePairState(did: string, rkey: string): Promise<void>;
// PDS record cache. Not secret; the same ciphertext the PDS would
// serve. Used for fast cold starts and offline reads.
cacheGetRecord<T>(did, collection, uri): Promise<CachedRecord<T> | null>;
cachePutRecords<T>(did, collection, records): Promise<void>;
cacheRemoveRecord(did, collection, uri): Promise<void>;
cacheGetCollection<T>(did, collection): Promise<CachedCollection<T> | null>;
cachePutCollection<T>(did, collection, data): Promise<void>;
cacheInvalidateCollection(did, collection): Promise<void>;
cacheClear(did): Promise<void>;
}Every method is async. Implementations can use any storage technology underneath, but the
contract is sequentially consistent per DID: a saveIdentity followed by loadIdentity
with the same DID must return what was written.
The four groups of methods serve different lifetime profiles. Config, identity, and session are small, read frequently, written rarely. Pair-state is ephemeral: short-lived entries created and deleted within a single pairing flow. The cache is high-volume and access-pattern-dominant; it's OK for cache implementations to prune aggressively or skip writes under pressure.
Built-in implementations #
import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
import { MemoryStorage } from "@opake/sdk";
// Browsers. Backed by Dexie; survives page reloads; scoped per origin.
const browserStorage = new IndexedDbStorage();
// Tests. All in-memory, discarded on process exit.
const testStorage = new MemoryStorage();IndexedDbStorage uses Dexie tables for config, identities, sessions, pair-states, and the
record cache. Database name is opake by default; pass a custom name to the constructor if
you need multiple parallel stores (rare, but useful for test isolation). Schema evolution is
Dexie-versioned; existing databases upgrade in place when the SDK schema bumps.
MemoryStorage is simpler: plain Map instances, no persistence. Fine for unit tests. Not
fine for anything a user would run, because identity keys vanish when the process exits.
Writing a custom backend #
The contract is small enough to implement from scratch. A rough sketch for a Tauri app using the file system:
import type { Storage, Config, Identity, Session } from "@opake/sdk";
class TauriFsStorage implements Storage {
constructor(private readonly dataDir: string) {}
async loadConfig(): Promise<Config> {
const raw = await fs.readFile(`${this.dataDir}/config.json`);
return JSON.parse(raw.toString()) as Config;
}
async saveConfig(config: Config): Promise<void> {
await fs.writeFile(
`${this.dataDir}/config.json`,
JSON.stringify(config, null, 2),
);
}
// ...identity, session, pair-state, cache methods follow the same
// pattern. Reference: MemoryStorage (src/storage/memory.ts) is a
// clean example of the full interface in one file.
}The identity, session, and pair-state paths follow the same load-file / save-file pattern. For the cache, you'd probably want a single SQLite database rather than one file per record; the cache API is designed around (did, collection, uri) tuples which map cleanly to SQL.
If you build one that's reusable, consider sending it upstream. The Storage contract is
small; most backends are one file plus tests. Keeping your implementation in the main repo
saves you from re-syncing it every time the trait evolves, and means other people on the
same platform find it next to the built-ins instead of re-solving the problem. Issues and
PRs at github.com/Opake-at/Opake.
What each method is called for #
loadConfig/saveConfig: called once duringOpake.initto resolve the target DID (or pick the default), and on every account-mutating operation (login, logout, switch default, remove account). Low-frequency.loadIdentity/saveIdentity:loadIdentityruns onOpake.init.saveIdentityruns during seed-phrase recovery and device-pairing completion. After that, the identity is in WASM memory for the session's lifetime and doesn't get re-read.loadSession/saveSession/clearSession:loadSessiononOpake.init.saveSessionon every token refresh (proactive or reactive), and on fresh login.clearSessionon logout without account removal.removeAccount: deletes everything for one DID atomically. The implementation should treat this as a transaction; a half-removed account will fail the nextOpake.initfor that DID in confusing ways.savePairState/loadPairState/deletePairState: called exclusively from inside WASM during device pairing. The bytes are the ephemeral X25519 private key for a pending pair request. Implementations should treat these bytes as secret and clear them from any intermediate buffers once the save completes.- Cache methods: called opportunistically. The SDK survives partial cache failures; if
cachePutRecordssilently no-ops on a particular record, the next operation that needs it just re-fetches from the PDS. The cache is correctness-preserving, not correctness-dependent.
Invariants implementations must respect #
- No leaking pair-state to JS userland.
loadPairState/savePairStatereturn and acceptUint8Array, but your implementation must not log, persist as plaintext in URLs or query strings, or otherwise serialize these bytes anywhere other than the backing store. IndexedDbStorage writes them as-is into an IDB row whose key is the(did, rkey)tuple; anything equivalent is fine. - Atomic
removeAccount. If a user clicks "log out and forget", and yourremoveAccountcrashes halfway through, the partial state becomes a support ticket. Use whatever transaction primitive your backend offers, even if it's "write to a temp and rename on success." - Sequential consistency per DID. Multiple in-flight
saveSessioncalls for the same DID during concurrent token refreshes must settle to the latest-written value. The SDK's@withTokenGuardserialises refreshes into a single-flight promise, so you won't see concurrent writes in practice, but your backend shouldn't rely on that. - No in-place mutation of returned objects. The SDK assumes
loadIdentity/loadSession/loadConfigreturn fresh copies each call. If your backend caches the parsed object and returns references to it, a consumer mutating the returned value could leak into subsequent loads.