release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
|
|
|
|
import type {
|
|
|
|
|
StorageProvider,
|
|
|
|
|
IdentityKeyPair,
|
|
|
|
|
SignedPreKey,
|
|
|
|
|
OneTimePreKey,
|
|
|
|
|
SessionState,
|
|
|
|
|
RetiredIdentity,
|
|
|
|
|
PersistedStreamState,
|
|
|
|
|
PeerVerification,
|
|
|
|
|
PeerVerificationSource,
|
2026-05-07 15:55:34 +02:00
|
|
|
BroadcastChannelRecord,
|
|
|
|
|
BroadcastMemberRecord,
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
} from '@shade/core';
|
|
|
|
|
import {
|
|
|
|
|
toBase64, fromBase64,
|
|
|
|
|
constantTimeEqual,
|
|
|
|
|
serializeSessionState, deserializeSessionState,
|
|
|
|
|
serializeSignedPreKey, deserializeSignedPreKey,
|
|
|
|
|
serializeOneTimePreKey, deserializeOneTimePreKey,
|
|
|
|
|
serializeIdentityKeyPair, deserializeIdentityKeyPair,
|
|
|
|
|
} from '@shade/core';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* IndexedDB-backed StorageProvider for browser-side Shade clients.
|
|
|
|
|
*
|
|
|
|
|
* Persists identity, prekeys, sessions, retired identities, peer
|
|
|
|
|
* verifications and stream-resume state across tab refresh and browser
|
|
|
|
|
* restart. Same data shapes as `@shade/storage-sqlite` so cross-adapter
|
|
|
|
|
* import/export remains feasible.
|
|
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* ```ts
|
|
|
|
|
* const storage = await IndexedDBStorage.create({ dbName: 'my-app-shade' });
|
|
|
|
|
* const manager = new ShadeSessionManager(crypto, storage);
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
export class IndexedDBStorage implements StorageProvider {
|
|
|
|
|
private constructor(private db: IDBPDatabase<ShadeSchema>) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Open (or create) the IndexedDB database. Idempotent — repeated calls
|
|
|
|
|
* with the same dbName resolve to a fresh connection sharing the same
|
|
|
|
|
* underlying object stores.
|
|
|
|
|
*/
|
|
|
|
|
static async create(opts: { dbName?: string } = {}): Promise<IndexedDBStorage> {
|
|
|
|
|
const dbName = opts.dbName ?? 'shade';
|
|
|
|
|
const db = await openDB<ShadeSchema>(dbName, SCHEMA_VERSION, {
|
|
|
|
|
upgrade(db, oldVersion) {
|
|
|
|
|
if (oldVersion < 1) {
|
|
|
|
|
db.createObjectStore('identity', { keyPath: 'id' });
|
|
|
|
|
db.createObjectStore('config', { keyPath: 'key' });
|
|
|
|
|
db.createObjectStore('signedPreKeys', { keyPath: 'keyId' });
|
|
|
|
|
db.createObjectStore('oneTimePreKeys', { keyPath: 'keyId' });
|
|
|
|
|
db.createObjectStore('sessions', { keyPath: 'address' });
|
|
|
|
|
db.createObjectStore('trustedIdentities', { keyPath: 'address' });
|
|
|
|
|
|
|
|
|
|
const retired = db.createObjectStore('retiredIdentities', {
|
|
|
|
|
keyPath: 'id',
|
|
|
|
|
autoIncrement: true,
|
|
|
|
|
});
|
|
|
|
|
retired.createIndex('byRetiredAt', 'retiredAt');
|
|
|
|
|
|
|
|
|
|
const stream = db.createObjectStore('streamStates', { keyPath: 'streamId' });
|
|
|
|
|
stream.createIndex('byStatus', 'status');
|
|
|
|
|
stream.createIndex('byPeerAddress', 'peerAddress');
|
|
|
|
|
stream.createIndex('byUpdatedAt', 'updatedAt');
|
|
|
|
|
|
|
|
|
|
db.createObjectStore('peerVerifications', { keyPath: 'peerAddress' });
|
|
|
|
|
db.createObjectStore('peerIdentityVersions', { keyPath: 'peerAddress' });
|
|
|
|
|
}
|
2026-05-07 15:55:34 +02:00
|
|
|
if (oldVersion < 2) {
|
|
|
|
|
db.createObjectStore('broadcastChannels', { keyPath: 'channelId' });
|
|
|
|
|
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
|
|
|
|
|
members.createIndex('byChannelId', 'channelId');
|
|
|
|
|
}
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
if (oldVersion < 3) {
|
|
|
|
|
const aliases = db.createObjectStore('sessionAliases', { keyPath: 'alias' });
|
|
|
|
|
aliases.createIndex('byCanonical', 'canonical');
|
|
|
|
|
}
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return new IndexedDBStorage(db);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Cleanly close the underlying connection. */
|
|
|
|
|
async close(): Promise<void> {
|
|
|
|
|
this.db.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Identity ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getIdentityKeyPair(): Promise<IdentityKeyPair | null> {
|
|
|
|
|
const row = await this.db.get('identity', 1);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return {
|
|
|
|
|
signingPublicKey: fromBase64(row.signingPublicKey),
|
|
|
|
|
signingPrivateKey: fromBase64(row.signingPrivateKey),
|
|
|
|
|
dhPublicKey: fromBase64(row.dhPublicKey),
|
|
|
|
|
dhPrivateKey: fromBase64(row.dhPrivateKey),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveIdentityKeyPair(kp: IdentityKeyPair): Promise<void> {
|
|
|
|
|
await this.db.put('identity', {
|
|
|
|
|
id: 1,
|
|
|
|
|
signingPublicKey: toBase64(kp.signingPublicKey),
|
|
|
|
|
signingPrivateKey: toBase64(kp.signingPrivateKey),
|
|
|
|
|
dhPublicKey: toBase64(kp.dhPublicKey),
|
|
|
|
|
dhPrivateKey: toBase64(kp.dhPrivateKey),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getLocalRegistrationId(): Promise<number> {
|
|
|
|
|
const row = await this.db.get('config', 'registrationId');
|
|
|
|
|
return row ? parseInt(row.value, 10) : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveLocalRegistrationId(id: number): Promise<void> {
|
|
|
|
|
await this.db.put('config', { key: 'registrationId', value: String(id) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Signed PreKeys ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSignedPreKey(keyId: number): Promise<SignedPreKey | null> {
|
|
|
|
|
const row = await this.db.get('signedPreKeys', keyId);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return deserializeSignedPreKey(row.dataJson);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSignedPreKey(key: SignedPreKey): Promise<void> {
|
|
|
|
|
await this.db.put('signedPreKeys', {
|
|
|
|
|
keyId: key.keyId,
|
|
|
|
|
dataJson: serializeSignedPreKey(key),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSignedPreKey(keyId: number): Promise<void> {
|
|
|
|
|
await this.db.delete('signedPreKeys', keyId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── One-Time PreKeys ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getOneTimePreKey(keyId: number): Promise<OneTimePreKey | null> {
|
|
|
|
|
const row = await this.db.get('oneTimePreKeys', keyId);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return deserializeOneTimePreKey(row.dataJson);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveOneTimePreKey(key: OneTimePreKey): Promise<void> {
|
|
|
|
|
await this.db.put('oneTimePreKeys', {
|
|
|
|
|
keyId: key.keyId,
|
|
|
|
|
dataJson: serializeOneTimePreKey(key),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeOneTimePreKey(keyId: number): Promise<void> {
|
|
|
|
|
await this.db.delete('oneTimePreKeys', keyId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getOneTimePreKeyCount(): Promise<number> {
|
|
|
|
|
return this.db.count('oneTimePreKeys');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Sessions ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSession(address: string): Promise<SessionState | null> {
|
|
|
|
|
const row = await this.db.get('sessions', address);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return deserializeSessionState(row.stateJson);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSession(address: string, state: SessionState): Promise<void> {
|
|
|
|
|
await this.db.put('sessions', { address, stateJson: serializeSessionState(state) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSession(address: string): Promise<void> {
|
|
|
|
|
await this.db.delete('sessions', address);
|
|
|
|
|
}
|
|
|
|
|
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
// ─── Session label aliases ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async getSessionAlias(alias: string): Promise<string | null> {
|
|
|
|
|
const row = await this.db.get('sessionAliases', alias);
|
|
|
|
|
return row?.canonical ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
|
|
|
|
|
await this.db.put('sessionAliases', { alias, canonical });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeSessionAliasesFor(canonical: string): Promise<void> {
|
|
|
|
|
const tx = this.db.transaction('sessionAliases', 'readwrite');
|
|
|
|
|
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
|
|
|
|
|
await Promise.all(matches.map((key) => tx.store.delete(key)));
|
|
|
|
|
await tx.done;
|
|
|
|
|
}
|
|
|
|
|
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
// ─── Trust ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
|
|
|
|
|
const row = await this.db.get('trustedIdentities', address);
|
|
|
|
|
if (!row) return true; // TOFU
|
|
|
|
|
const stored = fromBase64(row.identityKey);
|
|
|
|
|
return constantTimeEqual(stored, identityKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise<void> {
|
|
|
|
|
await this.db.put('trustedIdentities', { address, identityKey: toBase64(identityKey) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Identity History ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async addRetiredIdentity(identity: RetiredIdentity): Promise<void> {
|
|
|
|
|
// autoIncrement: omit `id` so IDB assigns one
|
|
|
|
|
await this.db.add('retiredIdentities', {
|
|
|
|
|
dataJson: serializeIdentityKeyPair(identity.keyPair),
|
|
|
|
|
retiredAt: identity.retiredAt,
|
|
|
|
|
} as RetiredIdentityRow);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getRetiredIdentities(): Promise<RetiredIdentity[]> {
|
|
|
|
|
// Mirror SQLite's `ORDER BY retired_at DESC`
|
|
|
|
|
const rows = await this.db.getAllFromIndex('retiredIdentities', 'byRetiredAt');
|
|
|
|
|
rows.reverse();
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
keyPair: deserializeIdentityKeyPair(r.dataJson),
|
|
|
|
|
retiredAt: r.retiredAt,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async pruneRetiredIdentities(olderThan: number): Promise<void> {
|
|
|
|
|
const tx = this.db.transaction('retiredIdentities', 'readwrite');
|
|
|
|
|
const idx = tx.store.index('byRetiredAt');
|
|
|
|
|
const range = IDBKeyRange.upperBound(olderThan, true);
|
|
|
|
|
let cursor = await idx.openCursor(range);
|
|
|
|
|
while (cursor) {
|
|
|
|
|
await cursor.delete();
|
|
|
|
|
cursor = await cursor.continue();
|
|
|
|
|
}
|
|
|
|
|
await tx.done;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Stream-transfer resume state ─────────────────────────
|
|
|
|
|
|
|
|
|
|
async saveStreamState(state: PersistedStreamState): Promise<void> {
|
|
|
|
|
await this.db.put('streamStates', persistedToRow(state));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getStreamState(streamId: string): Promise<PersistedStreamState | null> {
|
|
|
|
|
const row = await this.db.get('streamStates', streamId);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return rowToPersisted(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeStreamState(streamId: string): Promise<void> {
|
|
|
|
|
await this.db.delete('streamStates', streamId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listActiveStreamStates(direction?: 'send' | 'receive'): Promise<PersistedStreamState[]> {
|
|
|
|
|
const idx = this.db.transaction('streamStates').store.index('byStatus');
|
|
|
|
|
const active = await idx.getAll(IDBKeyRange.only('active'));
|
|
|
|
|
const paused = await idx.getAll(IDBKeyRange.only('paused'));
|
|
|
|
|
const merged = [...active, ...paused];
|
|
|
|
|
const filtered = direction === undefined
|
|
|
|
|
? merged
|
|
|
|
|
: merged.filter((r) => r.direction === direction);
|
|
|
|
|
filtered.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
|
|
|
return filtered.map(rowToPersisted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async pruneStreamStates(olderThan: number): Promise<void> {
|
|
|
|
|
const tx = this.db.transaction('streamStates', 'readwrite');
|
|
|
|
|
const idx = tx.store.index('byUpdatedAt');
|
|
|
|
|
const range = IDBKeyRange.upperBound(olderThan, true);
|
|
|
|
|
let cursor = await idx.openCursor(range);
|
|
|
|
|
while (cursor) {
|
|
|
|
|
const row = cursor.value;
|
|
|
|
|
if (row.status === 'finished' || row.status === 'aborted') {
|
|
|
|
|
await cursor.delete();
|
|
|
|
|
}
|
|
|
|
|
cursor = await cursor.continue();
|
|
|
|
|
}
|
|
|
|
|
await tx.done;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Peer verifications (V3.3) ────────────────────────────
|
|
|
|
|
|
|
|
|
|
async savePeerVerification(v: PeerVerification): Promise<void> {
|
|
|
|
|
await this.db.put('peerVerifications', { ...v });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getPeerVerification(address: string): Promise<PeerVerification | null> {
|
|
|
|
|
const row = await this.db.get('peerVerifications', address);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return {
|
|
|
|
|
peerAddress: row.peerAddress,
|
|
|
|
|
fingerprint: row.fingerprint,
|
|
|
|
|
verifiedAt: row.verifiedAt,
|
|
|
|
|
verifiedBy: row.verifiedBy as PeerVerificationSource,
|
|
|
|
|
identityVersion: row.identityVersion,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removePeerVerification(address: string): Promise<void> {
|
|
|
|
|
await this.db.delete('peerVerifications', address);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getPeerIdentityVersion(address: string): Promise<number> {
|
|
|
|
|
const row = await this.db.get('peerIdentityVersions', address);
|
|
|
|
|
return row ? row.version : 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Atomic read-modify-write under one IDB transaction. SQLite's version
|
|
|
|
|
* is a non-atomic read-then-upsert; the IDB version closes that race
|
|
|
|
|
* because IDB transactions auto-commit only when control returns to
|
|
|
|
|
* the event loop without pending requests.
|
|
|
|
|
*/
|
|
|
|
|
async bumpPeerIdentityVersion(address: string): Promise<number> {
|
|
|
|
|
const tx = this.db.transaction('peerIdentityVersions', 'readwrite');
|
|
|
|
|
const existing = await tx.store.get(address);
|
|
|
|
|
const next = (existing ? existing.version : 1) + 1;
|
|
|
|
|
await tx.store.put({ peerAddress: address, version: next });
|
|
|
|
|
await tx.done;
|
|
|
|
|
return next;
|
|
|
|
|
}
|
2026-05-07 15:55:34 +02:00
|
|
|
|
|
|
|
|
// ─── Broadcast channels (V4.6) ────────────────────────────
|
|
|
|
|
|
|
|
|
|
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
|
|
|
|
await this.db.put('broadcastChannels', channelToRow(channel));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
|
|
|
|
const row = await this.db.get('broadcastChannels', channelId);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
return rowToChannel(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
|
|
|
|
const rows = await this.db.getAll('broadcastChannels');
|
|
|
|
|
rows.sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
|
return rows.map(rowToChannel);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeBroadcastChannel(channelId: string): Promise<void> {
|
|
|
|
|
const tx = this.db.transaction(['broadcastChannels', 'broadcastMembers'], 'readwrite');
|
|
|
|
|
const memIdx = tx.objectStore('broadcastMembers').index('byChannelId');
|
|
|
|
|
let cursor = await memIdx.openCursor(IDBKeyRange.only(channelId));
|
|
|
|
|
while (cursor) {
|
|
|
|
|
await cursor.delete();
|
|
|
|
|
cursor = await cursor.continue();
|
|
|
|
|
}
|
|
|
|
|
await tx.objectStore('broadcastChannels').delete(channelId);
|
|
|
|
|
await tx.done;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
|
|
|
|
await this.db.put('broadcastMembers', { ...member });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
|
|
|
|
const rows = await this.db.getAllFromIndex(
|
|
|
|
|
'broadcastMembers',
|
|
|
|
|
'byChannelId',
|
|
|
|
|
IDBKeyRange.only(channelId),
|
|
|
|
|
);
|
|
|
|
|
rows.sort((a, b) => a.joinedAt - b.joinedAt);
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
channelId: r.channelId,
|
|
|
|
|
peerAddress: r.peerAddress,
|
|
|
|
|
joinedAt: r.joinedAt,
|
|
|
|
|
removedAt: r.removedAt,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
|
|
|
|
await this.db.delete('broadcastMembers', [channelId, peerAddress]);
|
|
|
|
|
}
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Schema ────────────────────────────────────────────────
|
|
|
|
|
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
const SCHEMA_VERSION = 3;
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
|
|
|
|
|
interface IdentityRow {
|
|
|
|
|
id: 1;
|
|
|
|
|
signingPublicKey: string;
|
|
|
|
|
signingPrivateKey: string;
|
|
|
|
|
dhPublicKey: string;
|
|
|
|
|
dhPrivateKey: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ConfigRow {
|
|
|
|
|
key: string;
|
|
|
|
|
value: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SignedPreKeyRow {
|
|
|
|
|
keyId: number;
|
|
|
|
|
dataJson: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface OneTimePreKeyRow {
|
|
|
|
|
keyId: number;
|
|
|
|
|
dataJson: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SessionRow {
|
|
|
|
|
address: string;
|
|
|
|
|
stateJson: string;
|
|
|
|
|
}
|
|
|
|
|
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
interface SessionAliasRow {
|
|
|
|
|
alias: string;
|
|
|
|
|
canonical: string;
|
|
|
|
|
}
|
|
|
|
|
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
interface TrustedIdentityRow {
|
|
|
|
|
address: string;
|
|
|
|
|
identityKey: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RetiredIdentityRow {
|
|
|
|
|
id?: number;
|
|
|
|
|
dataJson: string;
|
|
|
|
|
retiredAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StreamStateRow {
|
|
|
|
|
streamId: string;
|
|
|
|
|
direction: 'send' | 'receive';
|
|
|
|
|
peerAddress: string;
|
|
|
|
|
status: 'active' | 'paused' | 'finished' | 'aborted';
|
|
|
|
|
metadataJson: string;
|
|
|
|
|
partitionJson: string;
|
|
|
|
|
laneStateJson: string;
|
|
|
|
|
ioDescriptorJson: string;
|
|
|
|
|
secretEnc: Uint8Array;
|
|
|
|
|
secretNonce: Uint8Array;
|
|
|
|
|
overallHashState: string | null;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
updatedAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PeerVerificationRow {
|
|
|
|
|
peerAddress: string;
|
|
|
|
|
fingerprint: string;
|
|
|
|
|
verifiedAt: number;
|
|
|
|
|
verifiedBy: string;
|
|
|
|
|
identityVersion: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PeerIdentityVersionRow {
|
|
|
|
|
peerAddress: string;
|
|
|
|
|
version: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:55:34 +02:00
|
|
|
interface BroadcastChannelRow {
|
|
|
|
|
channelId: string;
|
|
|
|
|
ownerRole: 'sender' | 'receiver';
|
|
|
|
|
ownerAddress: string;
|
|
|
|
|
label: string | null;
|
|
|
|
|
generation: number;
|
|
|
|
|
chainKey: Uint8Array;
|
|
|
|
|
iteration: number;
|
|
|
|
|
signingPublicKey: Uint8Array;
|
|
|
|
|
signingPrivateKey: Uint8Array | null;
|
|
|
|
|
createdAt: number;
|
|
|
|
|
updatedAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BroadcastMemberRow {
|
|
|
|
|
channelId: string;
|
|
|
|
|
peerAddress: string;
|
|
|
|
|
joinedAt: number;
|
|
|
|
|
removedAt: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
interface ShadeSchema extends DBSchema {
|
|
|
|
|
identity: { key: number; value: IdentityRow };
|
|
|
|
|
config: { key: string; value: ConfigRow };
|
|
|
|
|
signedPreKeys: { key: number; value: SignedPreKeyRow };
|
|
|
|
|
oneTimePreKeys: { key: number; value: OneTimePreKeyRow };
|
|
|
|
|
sessions: { key: string; value: SessionRow };
|
fix(session): remember where aliasSession moved a session
aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.
First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.
Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.
StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.
A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.
The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.
Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.
Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:36:31 +02:00
|
|
|
sessionAliases: {
|
|
|
|
|
key: string;
|
|
|
|
|
value: SessionAliasRow;
|
|
|
|
|
indexes: { byCanonical: string };
|
|
|
|
|
};
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
trustedIdentities: { key: string; value: TrustedIdentityRow };
|
|
|
|
|
retiredIdentities: {
|
|
|
|
|
key: number;
|
|
|
|
|
value: RetiredIdentityRow;
|
|
|
|
|
indexes: { byRetiredAt: number };
|
|
|
|
|
};
|
|
|
|
|
streamStates: {
|
|
|
|
|
key: string;
|
|
|
|
|
value: StreamStateRow;
|
|
|
|
|
indexes: {
|
|
|
|
|
byStatus: string;
|
|
|
|
|
byPeerAddress: string;
|
|
|
|
|
byUpdatedAt: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
peerVerifications: { key: string; value: PeerVerificationRow };
|
|
|
|
|
peerIdentityVersions: { key: string; value: PeerIdentityVersionRow };
|
2026-05-07 15:55:34 +02:00
|
|
|
broadcastChannels: { key: string; value: BroadcastChannelRow };
|
|
|
|
|
broadcastMembers: {
|
|
|
|
|
key: [string, string];
|
|
|
|
|
value: BroadcastMemberRow;
|
|
|
|
|
indexes: { byChannelId: string };
|
|
|
|
|
};
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function persistedToRow(s: PersistedStreamState): StreamStateRow {
|
|
|
|
|
return {
|
|
|
|
|
streamId: s.streamId,
|
|
|
|
|
direction: s.direction,
|
|
|
|
|
peerAddress: s.peerAddress,
|
|
|
|
|
status: s.status,
|
|
|
|
|
metadataJson: s.metadataJson,
|
|
|
|
|
partitionJson: s.partitionJson,
|
|
|
|
|
laneStateJson: s.laneStateJson,
|
|
|
|
|
ioDescriptorJson: s.ioDescriptorJson,
|
|
|
|
|
secretEnc: s.secretEnc,
|
|
|
|
|
secretNonce: s.secretNonce,
|
|
|
|
|
overallHashState: s.overallHashState ?? null,
|
|
|
|
|
createdAt: s.createdAt,
|
|
|
|
|
updatedAt: s.updatedAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:55:34 +02:00
|
|
|
function channelToRow(c: BroadcastChannelRecord): BroadcastChannelRow {
|
|
|
|
|
return {
|
|
|
|
|
channelId: c.channelId,
|
|
|
|
|
ownerRole: c.ownerRole,
|
|
|
|
|
ownerAddress: c.ownerAddress,
|
|
|
|
|
label: c.label ?? null,
|
|
|
|
|
generation: c.generation,
|
|
|
|
|
chainKey: c.chainKey,
|
|
|
|
|
iteration: c.iteration,
|
|
|
|
|
signingPublicKey: c.signingPublicKey,
|
|
|
|
|
signingPrivateKey: c.signingPrivateKey ?? null,
|
|
|
|
|
createdAt: c.createdAt,
|
|
|
|
|
updatedAt: c.updatedAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rowToChannel(r: BroadcastChannelRow): BroadcastChannelRecord {
|
|
|
|
|
const out: BroadcastChannelRecord = {
|
|
|
|
|
channelId: r.channelId,
|
|
|
|
|
ownerRole: r.ownerRole,
|
|
|
|
|
ownerAddress: r.ownerAddress,
|
|
|
|
|
generation: r.generation,
|
|
|
|
|
chainKey: r.chainKey,
|
|
|
|
|
iteration: r.iteration,
|
|
|
|
|
signingPublicKey: r.signingPublicKey,
|
|
|
|
|
createdAt: r.createdAt,
|
|
|
|
|
updatedAt: r.updatedAt,
|
|
|
|
|
};
|
|
|
|
|
if (r.label !== null) out.label = r.label;
|
|
|
|
|
if (r.signingPrivateKey !== null) out.signingPrivateKey = r.signingPrivateKey;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00
|
|
|
function rowToPersisted(r: StreamStateRow): PersistedStreamState {
|
|
|
|
|
const out: PersistedStreamState = {
|
|
|
|
|
streamId: r.streamId,
|
|
|
|
|
direction: r.direction,
|
|
|
|
|
peerAddress: r.peerAddress,
|
|
|
|
|
status: r.status,
|
|
|
|
|
metadataJson: r.metadataJson,
|
|
|
|
|
partitionJson: r.partitionJson,
|
|
|
|
|
laneStateJson: r.laneStateJson,
|
|
|
|
|
ioDescriptorJson: r.ioDescriptorJson,
|
|
|
|
|
secretEnc: r.secretEnc,
|
|
|
|
|
secretNonce: r.secretNonce,
|
|
|
|
|
createdAt: r.createdAt,
|
|
|
|
|
updatedAt: r.updatedAt,
|
|
|
|
|
};
|
|
|
|
|
if (r.overallHashState !== null) out.overallHashState = r.overallHashState;
|
|
|
|
|
return out;
|
|
|
|
|
}
|