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

Binary file not shown.

View File

@@ -5,6 +5,11 @@ export type {
ShadeWebRtcConfig,
ShadeWebRtcRuntime,
} from './shade.js';
export type {
BroadcastChannel,
BroadcastChannelSummary,
MessageMeta,
} from './broadcast.js';
export { generateThumbnail } from './thumbnail.js';
export type { GeneratedThumbnail, ThumbnailGenerationOptions } from './thumbnail.js';
export { ShadeThumbnailCache } from './thumbnail-cache.js';

View File

@@ -44,6 +44,17 @@ import {
backupFromString,
} from './backup.js';
import { computeFingerprint, deserializeIdentityKeyPair } from '@shade/core';
import {
acceptBroadcastEnvelope,
createBroadcastChannelImpl,
getBroadcastChannelImpl,
listBroadcastChannelsImpl,
maybeHandleControlPlaintext,
type BroadcastChannel,
type BroadcastChannelSummary,
type BroadcastSdkHooks,
type MessageMeta,
} from './broadcast.js';
import type { ResolvedConfig, StorageSpec } from './config.js';
import {
ShadeControlChannel,
@@ -143,9 +154,11 @@ export class Shade {
// Per-address encrypt queue to serialize ratchet mutations
private encryptChains = new Map<string, Promise<unknown>>();
// Message handlers — may be sync or async; receive() awaits each.
// Message handlers — may be sync or async; receive() awaits each. The
// optional third arg distinguishes direct vs broadcast plaintexts;
// handlers registered without it work unchanged (V4.6 back-compat).
private messageHandlers: Array<
(from: string, plaintext: string) => void | Promise<void>
(from: string, plaintext: string, meta?: MessageMeta) => void | Promise<void>
> = [];
// Stream-transfer engine, lazily constructed on first use.
@@ -414,13 +427,26 @@ export class Shade {
* The caller provides the `from` address because the envelope itself
* doesn't authenticate the sender — that's determined by your transport
* layer (auth header, WebSocket peer, push notification metadata, etc.).
*
* V4.6: when the decrypted plaintext is a broadcast control message
* (sender-key distribution / revocation), the SDK consumes it
* internally and returns an empty string; user handlers do NOT fire.
* Apps therefore see only direct plaintexts here. Broadcast payloads
* arrive via {@link Shade.acceptBroadcast}.
*/
async receive(from: string, envelope: ShadeEnvelope): Promise<string> {
if (!this.initialized) throw new Error('Not initialized');
const plaintext = await this.manager.decrypt(from, envelope);
const consumed = await maybeHandleControlPlaintext(
this.broadcastHooks(),
from,
plaintext,
);
if (consumed) return '';
const meta: MessageMeta = { kind: 'direct' };
for (const handler of this.messageHandlers) {
try {
await handler(from, plaintext);
await handler(from, plaintext, meta);
} catch (err) {
console.error('[Shade] Message handler threw:', err);
}
@@ -428,9 +454,16 @@ export class Shade {
return plaintext;
}
/** Register a handler for incoming messages. Async handlers are awaited. */
/**
* Register a handler for incoming messages. Async handlers are awaited.
*
* V4.6: handlers may declare an optional `meta` parameter to discriminate
* direct (`meta.kind === 'direct'`) from broadcast (`meta.kind === 'broadcast'`)
* deliveries. Handlers that ignore the third arg keep working unchanged
* for direct messages.
*/
onMessage(
handler: (from: string, plaintext: string) => void | Promise<void>,
handler: (from: string, plaintext: string, meta?: MessageMeta) => void | Promise<void>,
): () => void {
this.messageHandlers.push(handler);
return () => {
@@ -438,6 +471,73 @@ export class Shade {
};
}
// ─── V4.6 Broadcast channels ───────────────────────────────
/**
* Create a new broadcast channel owned by this device. Returns a
* handle for adding/removing members, encrypting a single payload,
* and rotating on revocation. The channel id is opaque, stable
* across `shutdown()` / re-open, and persisted via the configured
* `StorageProvider`.
*/
async createBroadcastChannel(opts: { label?: string } = {}): Promise<BroadcastChannel> {
if (!this.initialized) throw new Error('Not initialized');
return createBroadcastChannelImpl(this.broadcastHooks(), opts);
}
/**
* Look up an existing sender-side broadcast channel by id. Returns
* `null` when the id is unknown OR when this device only holds a
* receiver-side copy (the receiver path uses `onMessage` for delivery
* — there is no app-facing handle on the receive side).
*/
async getBroadcastChannel(channelId: string): Promise<BroadcastChannel | null> {
if (!this.initialized) throw new Error('Not initialized');
return getBroadcastChannelImpl(this.broadcastHooks(), channelId);
}
/**
* Snapshot of every broadcast channel persisted on this device,
* including receiver-side channels that we joined. Useful for
* rebuilding UI state on startup.
*/
async listBroadcastChannels(): Promise<readonly BroadcastChannelSummary[]> {
if (!this.initialized) throw new Error('Not initialized');
return listBroadcastChannelsImpl(this.broadcastHooks());
}
/**
* Hand a wire-encoded broadcast envelope (type 0x21) to the SDK.
* Decrypts via the matching channel, advances the chain, and dispatches
* the plaintext to `onMessage` handlers with `meta.kind === 'broadcast'`.
*
* Stale generations (sender's old chain after a rotation we already
* received) are silently dropped. Future generations (we haven't seen
* the rotation distribution yet) throw — the app should ensure the
* distribution envelope is delivered before the broadcast.
*/
async acceptBroadcast(envelope: Uint8Array): Promise<void> {
if (!this.initialized) throw new Error('Not initialized');
const result = await acceptBroadcastEnvelope(this.broadcastHooks(), envelope);
if (result === null) return;
for (const handler of this.messageHandlers) {
try {
await handler(result.meta.kind === 'broadcast' ? result.meta.sender : '', result.plaintext, result.meta);
} catch (err) {
console.error('[Shade] Broadcast handler threw:', err);
}
}
}
private broadcastHooks(): BroadcastSdkHooks {
return {
bilateralSend: (peer, pt) => this.send(peer, pt),
myAddress: () => this.address,
crypto: this.crypto,
storage: this.storage,
};
}
/** Get a peer's fingerprint (requires an existing session) */
async getFingerprintFor(address: string): Promise<string> {
if (!this.initialized) throw new Error('Not initialized');