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,4 +1,8 @@
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification } from '@shade/core';
import type {
StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey,
SessionState, RetiredIdentity, PersistedStreamState, PeerVerification,
BroadcastChannelRecord, BroadcastMemberRecord,
} from '@shade/core';
import { constantTimeEqual } from '@shade/core';
/**
@@ -167,4 +171,62 @@ export class MemoryStorage implements StorageProvider {
}
}
}
// ─── Broadcast channels (V4.6) ────────────────────────────
private broadcastChannels = new Map<string, BroadcastChannelRecord>();
private broadcastMembers = new Map<string, Map<string, BroadcastMemberRecord>>();
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
this.broadcastChannels.set(channel.channelId, cloneChannel(channel));
}
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
const v = this.broadcastChannels.get(channelId);
return v ? cloneChannel(v) : null;
}
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
return [...this.broadcastChannels.values()].map(cloneChannel);
}
async removeBroadcastChannel(channelId: string): Promise<void> {
this.broadcastChannels.delete(channelId);
this.broadcastMembers.delete(channelId);
}
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
let inner = this.broadcastMembers.get(member.channelId);
if (!inner) {
inner = new Map();
this.broadcastMembers.set(member.channelId, inner);
}
inner.set(member.peerAddress, { ...member });
}
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
const inner = this.broadcastMembers.get(channelId);
return inner ? [...inner.values()].map((m) => ({ ...m })) : [];
}
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
this.broadcastMembers.get(channelId)?.delete(peerAddress);
}
}
function cloneChannel(c: BroadcastChannelRecord): BroadcastChannelRecord {
const out: BroadcastChannelRecord = {
channelId: c.channelId,
ownerRole: c.ownerRole,
ownerAddress: c.ownerAddress,
generation: c.generation,
chainKey: new Uint8Array(c.chainKey),
iteration: c.iteration,
signingPublicKey: new Uint8Array(c.signingPublicKey),
createdAt: c.createdAt,
updatedAt: c.updatedAt,
};
if (c.label !== undefined) out.label = c.label;
if (c.signingPrivateKey !== undefined) out.signingPrivateKey = new Uint8Array(c.signingPrivateKey);
return out;
}