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-sqlite",
|
||||
"version": "4.5.0",
|
||||
"version": "4.6.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity, PersistedStreamState, PeerVerification, PeerVerificationSource } from '@shade/core';
|
||||
import type {
|
||||
StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey,
|
||||
SessionState, RetiredIdentity, PersistedStreamState, PeerVerification,
|
||||
PeerVerificationSource, BroadcastChannelRecord, BroadcastMemberRecord,
|
||||
} from '@shade/core';
|
||||
import {
|
||||
toBase64, fromBase64,
|
||||
constantTimeEqual,
|
||||
@@ -53,6 +57,14 @@ export class SQLiteStorage implements StorageProvider {
|
||||
removePeerVerification: ReturnType<Database['prepare']>;
|
||||
getPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||
upsertPeerIdentityVersion: ReturnType<Database['prepare']>;
|
||||
saveBroadcastChannel: ReturnType<Database['prepare']>;
|
||||
getBroadcastChannel: ReturnType<Database['prepare']>;
|
||||
listBroadcastChannels: ReturnType<Database['prepare']>;
|
||||
removeBroadcastChannel: ReturnType<Database['prepare']>;
|
||||
removeBroadcastChannelMembers: ReturnType<Database['prepare']>;
|
||||
saveBroadcastMember: ReturnType<Database['prepare']>;
|
||||
getBroadcastMembers: ReturnType<Database['prepare']>;
|
||||
removeBroadcastMember: ReturnType<Database['prepare']>;
|
||||
};
|
||||
|
||||
constructor(dbPath?: string) {
|
||||
@@ -127,6 +139,27 @@ export class SQLiteStorage implements StorageProvider {
|
||||
peer_address TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS broadcast_channels (
|
||||
channel_id TEXT PRIMARY KEY,
|
||||
owner_role TEXT NOT NULL,
|
||||
owner_address TEXT NOT NULL,
|
||||
label TEXT,
|
||||
generation INTEGER NOT NULL,
|
||||
chain_key BLOB NOT NULL,
|
||||
iteration INTEGER NOT NULL,
|
||||
signing_public_key BLOB NOT NULL,
|
||||
signing_private_key BLOB,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS broadcast_members (
|
||||
channel_id TEXT NOT NULL,
|
||||
peer_address TEXT NOT NULL,
|
||||
joined_at INTEGER NOT NULL,
|
||||
removed_at INTEGER,
|
||||
PRIMARY KEY (channel_id, peer_address)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_broadcast_members_channel ON broadcast_members(channel_id);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -183,6 +216,36 @@ export class SQLiteStorage implements StorageProvider {
|
||||
`INSERT INTO peer_identity_versions (peer_address, version) VALUES (?, ?)
|
||||
ON CONFLICT(peer_address) DO UPDATE SET version = excluded.version`,
|
||||
),
|
||||
saveBroadcastChannel: this.db.prepare(
|
||||
`INSERT OR REPLACE INTO broadcast_channels
|
||||
(channel_id, owner_role, owner_address, label, generation,
|
||||
chain_key, iteration, signing_public_key, signing_private_key,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
),
|
||||
getBroadcastChannel: this.db.prepare(
|
||||
'SELECT * FROM broadcast_channels WHERE channel_id = ?',
|
||||
),
|
||||
listBroadcastChannels: this.db.prepare(
|
||||
'SELECT * FROM broadcast_channels ORDER BY created_at ASC',
|
||||
),
|
||||
removeBroadcastChannel: this.db.prepare(
|
||||
'DELETE FROM broadcast_channels WHERE channel_id = ?',
|
||||
),
|
||||
removeBroadcastChannelMembers: this.db.prepare(
|
||||
'DELETE FROM broadcast_members WHERE channel_id = ?',
|
||||
),
|
||||
saveBroadcastMember: this.db.prepare(
|
||||
`INSERT OR REPLACE INTO broadcast_members
|
||||
(channel_id, peer_address, joined_at, removed_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
),
|
||||
getBroadcastMembers: this.db.prepare(
|
||||
'SELECT channel_id, peer_address, joined_at, removed_at FROM broadcast_members WHERE channel_id = ? ORDER BY joined_at ASC',
|
||||
),
|
||||
removeBroadcastMember: this.db.prepare(
|
||||
'DELETE FROM broadcast_members WHERE channel_id = ? AND peer_address = ?',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,6 +455,103 @@ export class SQLiteStorage implements StorageProvider {
|
||||
this.stmts.upsertPeerIdentityVersion.run(address, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// ─── Broadcast channels (V4.6) ────────────────────────────
|
||||
|
||||
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
|
||||
this.stmts.saveBroadcastChannel.run(
|
||||
channel.channelId,
|
||||
channel.ownerRole,
|
||||
channel.ownerAddress,
|
||||
channel.label ?? null,
|
||||
channel.generation,
|
||||
channel.chainKey,
|
||||
channel.iteration,
|
||||
channel.signingPublicKey,
|
||||
channel.signingPrivateKey ?? null,
|
||||
channel.createdAt,
|
||||
channel.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
|
||||
const row = this.stmts.getBroadcastChannel.get(channelId) as BroadcastChannelRow | undefined;
|
||||
if (!row) return null;
|
||||
return rowToBroadcastChannel(row);
|
||||
}
|
||||
|
||||
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
|
||||
const rows = this.stmts.listBroadcastChannels.all() as BroadcastChannelRow[];
|
||||
return rows.map(rowToBroadcastChannel);
|
||||
}
|
||||
|
||||
async removeBroadcastChannel(channelId: string): Promise<void> {
|
||||
this.stmts.removeBroadcastChannelMembers.run(channelId);
|
||||
this.stmts.removeBroadcastChannel.run(channelId);
|
||||
}
|
||||
|
||||
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
|
||||
this.stmts.saveBroadcastMember.run(
|
||||
member.channelId,
|
||||
member.peerAddress,
|
||||
member.joinedAt,
|
||||
member.removedAt,
|
||||
);
|
||||
}
|
||||
|
||||
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
|
||||
const rows = this.stmts.getBroadcastMembers.all(channelId) as BroadcastMemberRow[];
|
||||
return rows.map((r) => ({
|
||||
channelId: r.channel_id,
|
||||
peerAddress: r.peer_address,
|
||||
joinedAt: Number(r.joined_at),
|
||||
removedAt: r.removed_at === null || r.removed_at === undefined ? null : Number(r.removed_at),
|
||||
}));
|
||||
}
|
||||
|
||||
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
|
||||
this.stmts.removeBroadcastMember.run(channelId, peerAddress);
|
||||
}
|
||||
}
|
||||
|
||||
interface BroadcastChannelRow {
|
||||
channel_id: string;
|
||||
owner_role: 'sender' | 'receiver';
|
||||
owner_address: string;
|
||||
label: string | null;
|
||||
generation: number | bigint;
|
||||
chain_key: Uint8Array | ArrayBuffer;
|
||||
iteration: number | bigint;
|
||||
signing_public_key: Uint8Array | ArrayBuffer;
|
||||
signing_private_key: Uint8Array | ArrayBuffer | null;
|
||||
created_at: number | bigint;
|
||||
updated_at: number | bigint;
|
||||
}
|
||||
|
||||
interface BroadcastMemberRow {
|
||||
channel_id: string;
|
||||
peer_address: string;
|
||||
joined_at: number | bigint;
|
||||
removed_at: number | bigint | null;
|
||||
}
|
||||
|
||||
function rowToBroadcastChannel(r: BroadcastChannelRow): BroadcastChannelRecord {
|
||||
const out: BroadcastChannelRecord = {
|
||||
channelId: r.channel_id,
|
||||
ownerRole: r.owner_role,
|
||||
ownerAddress: r.owner_address,
|
||||
generation: Number(r.generation),
|
||||
chainKey: toBytes(r.chain_key),
|
||||
iteration: Number(r.iteration),
|
||||
signingPublicKey: toBytes(r.signing_public_key),
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
};
|
||||
if (r.label !== null && r.label !== undefined) out.label = r.label;
|
||||
if (r.signing_private_key !== null && r.signing_private_key !== undefined) {
|
||||
out.signingPrivateKey = toBytes(r.signing_private_key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rowToStreamState(row: any): PersistedStreamState {
|
||||
|
||||
Reference in New Issue
Block a user