Skip to content

Part 2 — the device

const engine = await SkmEngine.open({
memberId: me.id,
deviceId,
transport,
store,
keyStore,
kinds,
// optional
deviceLabel: "sam's laptop",
autoGrant: true, // default: grant other devices' key gaps during sync
sign: true, // default: sign every record
requireSignatures: true, // default: reject unsigned/unverifiable records
pushDebounceMs: 250, // default: how long a write waits for its neighbours
retry: { attempts: 4, baseMs: 250, maxMs: 8_000 },
metadata: undefined, // partial-sync metadata — see Part 10
now: () => Date.now(),
onEvent: (evt) => console.debug(evt),
});

open generates this device’s keypairs on first run (stored in the KeyStore), registers the public halves with the relay, and loads any keyrings it already holds.

const deviceId = localStorage.getItem('skm-device') ?? SkmEngine.newDeviceId();
localStorage.setItem('skm-device', deviceId);

Stable across restarts, unique per installation. A device id already bound to public keys is never rebound — that is the redirect attack — so registering an id with different keys is refused with 409 and status.deviceOk goes false. The correct response to a 409 is a new device id, never a retry.

import { bestStore, IdbGraphStore, MemoryGraphStore } from '@skm/client';
import { NodeSqliteGraphStore } from '@skm/client/node';
await bestStore({ name: me.id }); // browser: OPFS-SQLite, else IndexedDB
new IdbGraphStore('skm-' + me.id); // browser: IndexedDB, explicitly (a db name)
new NodeSqliteGraphStore({ path: 'data/sam.db' }); // Node / Electron main
new MemoryGraphStore(); // tests, ephemeral agents

OPFS is one tab at a time. bestStore falls back to IndexedDB in a second tab, so two tabs of one app hold two different local stores. They converge through the relay, but neither sees the other’s unsynced writes. If that matters to your UI, use IdbGraphStore everywhere, or run the engine in a SharedWorker.

import { IdbKeyStore, MemoryKeyStore } from '@skm/client';
import { FileKeyStore } from '@skm/client/node';
new IdbKeyStore(); // browser (optional db name, default 'skm-keys')
new FileKeyStore('data/sam-keys'); // server-side agents
new MemoryKeyStore(); // tests

FileKeyStore is plaintext on disk (mode 0700, one file per slot, and a destroy() for tearing a device down). Right for a server-side agent whose disk is already the trust boundary; wrong for a laptop, where the OS keychain is the answer. KeyStore is three methods — get(slot), set(slot, value), keys(prefix) — so wrapping Keychain, DPAPI, or libsecret is about thirty lines.

import { defineKinds } from '@skm/client';
import { z } from 'zod';
const kinds = defineKinds({
note: z.object({ body: z.string().max(10_000) }),
task: z.object({ title: z.string(), done: z.boolean(), due: z.number().optional() }),
});

A decrypted record is untrusted input. The relay cannot validate what it cannot read, and any member holding the key can craft any record — so the device validates after opening and skips what fails. Without a KindRegistry, a member with the key can write anything into every other member’s store.

A validator can be a zod schema or a plain predicate:

defineKinds({ blob: (data: unknown) => typeof (data as any)?.ref === 'string' });

The second argument decides what happens to a kind this build has never heard of:

defineKinds({ ... }, 'accept') // default — a newer client's records still sync
defineKinds({ ... }, 'skip') // stricter, for a closed record set

'accept' is the default because during a rolling deploy the alternative is an old tab quietly eating new data.

new HttpTransport({
baseUrl: '/api/skm',
headers: async () => ({ authorization: `Bearer ${await session.token()}` }),
credentials: 'same-origin', // only meaningful same-origin
timeoutMs: 30_000,
fetch: myFetch, // for shells whose fetch is not global
});

headers is called per request, so a rotating token stays fresh.

Failures surface as TransportError with status and retryable (true for network failures, 429, and 5xx). Transport is an interface if you want to run skm over something that is not HTTP.