release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

Lands the broadcast-channel primitive Prism asked for in
Docs/shade-feature-request-sender-keys.md. The crypto in
@shade/core/sender-keys.ts was already in place; this release wires
it up as a first-class app-facing API, adds the persistence schema
across all six storage backends (memory, sqlite, indexeddb +
encrypted variants), introduces wire type 0x21 in @shade/proto,
and ships Prism's three acceptance tests verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:55:34 +02:00
parent 2b1b4d6630
commit 2c400d7094
42 changed files with 1606 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-indexeddb",
"version": "4.5.0",
"version": "4.6.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -9,6 +9,8 @@ import type {
PersistedStreamState,
PeerVerification,
PeerVerificationSource,
BroadcastChannelRecord,
BroadcastMemberRecord,
} from '@shade/core';
import {
toBase64, fromBase64,
@@ -67,6 +69,11 @@ export class IndexedDBStorage implements StorageProvider {
db.createObjectStore('peerVerifications', { keyPath: 'peerAddress' });
db.createObjectStore('peerIdentityVersions', { keyPath: 'peerAddress' });
}
if (oldVersion < 2) {
db.createObjectStore('broadcastChannels', { keyPath: 'channelId' });
const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] });
members.createIndex('byChannelId', 'channelId');
}
},
});
return new IndexedDBStorage(db);
@@ -296,11 +303,64 @@ export class IndexedDBStorage implements StorageProvider {
await tx.done;
return next;
}
// ─── 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]);
}
}
// ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
interface IdentityRow {
id: 1;
@@ -370,6 +430,27 @@ interface PeerIdentityVersionRow {
version: number;
}
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;
}
interface ShadeSchema extends DBSchema {
identity: { key: number; value: IdentityRow };
config: { key: string; value: ConfigRow };
@@ -393,6 +474,12 @@ interface ShadeSchema extends DBSchema {
};
peerVerifications: { key: string; value: PeerVerificationRow };
peerIdentityVersions: { key: string; value: PeerIdentityVersionRow };
broadcastChannels: { key: string; value: BroadcastChannelRow };
broadcastMembers: {
key: [string, string];
value: BroadcastMemberRow;
indexes: { byChannelId: string };
};
}
// ─── Helpers ──────────────────────────────────────────────
@@ -415,6 +502,39 @@ function persistedToRow(s: PersistedStreamState): StreamStateRow {
};
}
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;
}
function rowToPersisted(r: StreamStateRow): PersistedStreamState {
const out: PersistedStreamState = {
streamId: r.streamId,