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
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user