relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
453 lines (436 loc) • 20.3 kB
JavaScript
import { createHash } from "node:crypto";
import {
createE2eeGroupBootstrap,
sendE2eeGroupEvent,
shareE2eeGroupHistory,
syncE2eeGroupEvents,
syncE2eeGroupHistoryArchives,
syncE2eeGroupWelcomes,
syncE2eeGroupEpochUpdates,
updateE2eeGroupMembership,
} from "./e2ee-groups.js";
import {
attachmentInputs,
attachmentRequestBindings,
cacheE2eeAttachmentInputs,
decryptAttachmentMetadata,
encryptAttachments,
ensureE2eeKeyPackages,
identityOrThrow,
materializeE2eeAttachments,
} from "./e2ee-mls.js";
import {
readEncryptedGroupState,
readPendingGroupOutbox,
readProcessedGroupEvent,
readProcessedGroupEvents,
removeLocalE2eeAttachmentDirectory,
removeProcessedGroupEvent,
removeEncryptedGroupState,
} from "./e2ee-state.js";
const PRODUCT_MESSAGE = "relay.message";
const PRODUCT_EDIT = "relay.edited";
const PRODUCT_DELETE = "relay.deleted";
const PRODUCT_REACTION = "relay.reaction";
const PRODUCT_RECEIPT = "relay.receipt";
async function groupView(client, groupId) {
const groups = (await client.groups()).groups || [];
const group = groups.find((item) => item.id === groupId);
if (!group) throw new Error("That encrypted group is not visible to this account.");
if (group.archivedAt) throw new Error("That group is archived and cannot receive new messages.");
return group;
}
/** Bootstrap once, then converge the local MLS epoch to the visible roster. */
export async function ensureE2eeGroupProductReady(client, groupId, idempotencyKey) {
const identity = identityOrThrow();
await ensureE2eeKeyPackages(client);
await syncE2eeGroupWelcomes(client);
await syncE2eeGroupEpochUpdates(client);
let local = readEncryptedGroupState(identity, groupId);
if (!local) {
try {
const preparation = await client.e2eePrepareGroup({
groupId,
idempotencyKey: `${idempotencyKey}:prepare`,
});
const bootstrap = await createE2eeGroupBootstrap(client, preparation);
await client.e2eeBootstrapGroup({
preparationId: bootstrap.preparationId,
groupId: bootstrap.groupId,
membershipRevision: bootstrap.membershipRevision,
epoch: bootstrap.epoch,
welcome: bootstrap.welcome,
ratchetTree: bootstrap.ratchetTree,
senderCheckpoint: bootstrap.senderCheckpoint,
idempotencyKey: `${idempotencyKey}:bootstrap`,
});
local = readEncryptedGroupState(identity, groupId);
} catch (error) {
if (error?.body?.error !== "group_already_exists") throw error;
// Another device won bootstrap. Its Welcome is the only safe state to
// keep; a locally-created losing branch must never be used for sends.
removeEncryptedGroupState(identity, groupId);
await syncE2eeGroupWelcomes(client);
local = readEncryptedGroupState(identity, groupId);
if (!local) throw new Error("Another device created this encrypted group. Bring that device online once so this device can join.");
}
}
let transition = null;
try {
transition = await updateE2eeGroupMembership(client, {
groupId,
idempotencyKey: `${idempotencyKey}:roster`,
});
} catch (error) {
if (error?.body?.error !== "group_current") throw error;
}
if (transition?.addedDeviceIds?.length) {
await shareE2eeGroupHistory(client, {
groupId,
recipientDeviceIds: transition.addedDeviceIds,
idempotencyKey: `${idempotencyKey}:history`,
});
}
return readEncryptedGroupState(identity, groupId);
}
function cleanSource(source) {
if (!source) return undefined;
const { cwd: _cwd, threadId: _threadId, authKind: _auth, clientVersion: _version, sendContract: _contract, ...safe } = source;
return Object.keys(safe).length ? safe : undefined;
}
export async function sendE2eeGroupRelay(client, payload) {
const groupId = String(payload.recipient?.groupId || payload.recipient?.chatId || "");
if (!groupId.startsWith("grp_")) throw new Error("Encrypted group sends require a saved group id.");
if ((payload.files || []).length) throw new Error("Encrypted file paths must be prepared locally before sending.");
const group = await groupView(client, groupId);
const local = await ensureE2eeGroupProductReady(client, groupId, payload.idempotencyKey);
if (!local) throw new Error("This device could not establish the encrypted group.");
const identity = identityOrThrow();
const eventId = `egmsg_${createHash("sha256")
.update("relay-e2ee-group-message-id-v1")
.update(groupId)
.update(identity.deviceId)
.update(String(payload.idempotencyKey || ""))
.digest("base64url")}`;
const cached = readProcessedGroupEvent(identity, groupId, eventId);
const pending = readPendingGroupOutbox(identity, groupId, eventId);
if (cached && pending) {
const replay = await sendE2eeGroupEvent(client, {
eventId,
groupId,
membershipRevision: cached.membershipRevision,
type: cached.type,
body: cached.body,
...(cached.conversation?.replyToMessageId ? { replyToMessageId: cached.conversation.replyToMessageId } : {}),
attachments: pending.wire.attachments || [],
historyImport: payload.historyImport === true,
idempotencyKey: payload.idempotencyKey,
});
return groupSendResponse(group, payload, replay);
}
const inputs = attachmentInputs(payload);
const attachmentEnvelopes = encryptAttachments(eventId, inputs);
const attachmentKeys = attachmentEnvelopes.map((attachment) => attachment.key);
const attachmentWires = attachmentEnvelopes.map((attachment) => attachment.wire);
const sent = await sendE2eeGroupEvent(client, {
eventId,
groupId,
membershipRevision: local.membershipRevision,
type: PRODUCT_MESSAGE,
body: {
version: 1,
kind: payload.kind || "message",
...(payload.type ? { relayType: payload.type } : {}),
...(String(payload.title || "").trim() ? { title: String(payload.title).trim() } : {}),
forHuman: String(payload.forHuman || ""),
forAgent: String(payload.forAgent || ""),
targetSurfaces: [...(payload.targetSurfaces || [])],
...(cleanSource(payload.source) ? { source: cleanSource(payload.source) } : {}),
attachments: attachmentKeys,
attachmentBindings: attachmentRequestBindings(inputs),
...(payload.historyImport && payload.historyImportEdited ? { historyImportEdited: true } : {}),
...(payload.historyImport && payload.historyImportDeleted ? { historyImportDeleted: true } : {}),
},
attachments: attachmentWires,
historyImport: payload.historyImport === true,
...(payload.inReplyToRelayId ? { replyToMessageId: payload.inReplyToRelayId } : {}),
idempotencyKey: payload.idempotencyKey,
});
const metadata = decryptAttachmentMetadata(eventId, attachmentKeys, attachmentWires);
cacheE2eeAttachmentInputs(eventId, inputs, metadata);
return groupSendResponse(group, payload, sent);
}
function groupSendResponse(group, payload, sent) {
return {
relayId: sent.eventId,
state: "delivered",
deliveredVia: "device",
recipient: { name: group.name, onRelay: true },
attachments: (payload.attachments || []).map(({ contentBase64: _content, ...attachment }) => attachment),
uploads: [],
...(payload.inReplyToRelayId ? { inReplyToRelayId: payload.inReplyToRelayId } : {}),
threadId: payload.inReplyToRelayId || sent.eventId,
groupSendId: sent.eventId,
groupRecipients: group.members.map((member) => ({
relayId: sent.eventId,
state: "delivered",
recipient: { name: member.name, email: member.email, onRelay: member.onRelay },
})),
};
}
export async function sendE2eeGroupChange(client, targetEventId, type, body, idempotencyKey) {
const records = await e2eeGroupOpenedRecords(client);
const target = records.find((record) => record.eventId === targetEventId);
if (!target) throw new Error("Encrypted group message not found on this device.");
const local = readEncryptedGroupState(identityOrThrow(), target.groupId);
if (!local) throw new Error("This device has not joined that encrypted group.");
return sendE2eeGroupEvent(client, {
groupId: target.groupId,
membershipRevision: local.membershipRevision,
type,
messageId: targetEventId,
threadId: target.plaintext.conversation?.threadId || targetEventId,
body,
idempotencyKey,
});
}
export async function syncE2eeGroupProduct(client) {
await ensureE2eeKeyPackages(client);
await syncE2eeGroupEvents(client);
await syncE2eeGroupHistoryArchives(client);
const identity = identityOrThrow();
let records = readProcessedGroupEvents(identity);
for (const record of records) {
if (record.plaintext?.type !== PRODUCT_DELETE) continue;
removeProcessedGroupEvent(identity, record.groupId, record.plaintext.messageId);
removeLocalE2eeAttachmentDirectory(record.plaintext.messageId);
}
records = readProcessedGroupEvents(identity);
return records.map((record) => {
if (record.plaintext?.type !== PRODUCT_MESSAGE || !record.wire) return record;
const keys = record.plaintext.body?.attachments || [];
const attachments = record.wire.attachments || [];
return {
...record,
plaintext: {
...record.plaintext,
body: {
...record.plaintext.body,
attachmentMetadata: decryptAttachmentMetadata(record.eventId, keys, attachments),
},
},
};
});
}
function project(records, identity) {
const ordered = [...records].sort((a, b) =>
String(a.plaintext?.authoredAt).localeCompare(String(b.plaintext?.authoredAt)) || a.eventId.localeCompare(b.eventId));
const messages = new Map();
const receipts = new Map();
for (const record of ordered) {
const { plaintext } = record;
if (plaintext?.type === PRODUCT_MESSAGE && plaintext.body?.version === 1) {
messages.set(plaintext.messageId, {
...record,
body: { ...plaintext.body },
updatedAt: plaintext.authoredAt,
editedAt: plaintext.body.historyImportEdited ? plaintext.authoredAt : null,
deletedAt: plaintext.body.historyImportDeleted ? plaintext.authoredAt : null,
reactions: new Map(),
});
continue;
}
const target = messages.get(plaintext?.messageId);
if (!target) continue;
if (plaintext.type === PRODUCT_EDIT && plaintext.senderUserId === target.plaintext.senderUserId) {
if (plaintext.body?.forHuman !== undefined) target.body.forHuman = String(plaintext.body.forHuman || "");
if (plaintext.body?.forAgent !== undefined) target.body.forAgent = String(plaintext.body.forAgent || "");
target.editedAt = plaintext.authoredAt;
target.updatedAt = plaintext.authoredAt;
} else if (plaintext.type === PRODUCT_DELETE && plaintext.senderUserId === target.plaintext.senderUserId) {
target.deletedAt = plaintext.authoredAt;
target.updatedAt = plaintext.authoredAt;
} else if (plaintext.type === PRODUCT_REACTION) {
const emoji = String(plaintext.body?.emoji || "");
const key = `${plaintext.senderUserId}\n${emoji}`;
if (plaintext.body?.action === "add") target.reactions.set(key, { emoji, senderUserId: plaintext.senderUserId });
else target.reactions.delete(key);
} else if (plaintext.type === PRODUCT_RECEIPT) {
const current = receipts.get(plaintext.messageId) || new Map();
current.set(plaintext.senderUserId, { state: plaintext.body?.state, at: plaintext.authoredAt });
receipts.set(plaintext.messageId, current);
}
}
return [...messages.values()].map((message) => {
const inbound = message.plaintext.senderUserId !== identity.userId;
const ownReceipt = receipts.get(message.eventId)?.get(identity.userId);
const aggregates = new Map();
for (const reaction of message.reactions.values()) {
const aggregate = aggregates.get(reaction.emoji) || { emoji: reaction.emoji, count: 0, reactedByMe: false, actors: [] };
aggregate.count += 1;
aggregate.reactedByMe ||= reaction.senderUserId === identity.userId;
aggregate.actors.push({ relayUserId: reaction.senderUserId, name: reaction.senderUserId === identity.userId ? "You" : "Group member", self: reaction.senderUserId === identity.userId });
aggregates.set(reaction.emoji, aggregate);
}
const readReceipts = inbound ? [] : [...(receipts.get(message.eventId)?.entries() || [])]
.filter(([userId, receipt]) => userId !== identity.userId && receipt.state === "read")
.map(([userId, receipt]) => ({ name: userId, seen: true, readAt: receipt.at }));
const item = {
relayId: message.eventId,
state: message.plaintext.historyImport ? "acknowledged" : ownReceipt?.state === "read" ? "read" : "delivered",
createdAt: message.plaintext.authoredAt,
updatedAt: message.updatedAt,
...(message.editedAt ? { editedAt: message.editedAt } : {}),
...(message.deletedAt ? { deletedAt: message.deletedAt } : {}),
kind: message.body.kind || "message",
...(message.body.title ? { title: message.body.title, displayTitle: message.body.title } : {}),
sender: message.wire?.sender || { relayUserId: message.plaintext.senderUserId, name: inbound ? "Group member" : "You" },
preview: message.deletedAt ? "Message deleted" : message.body.forHuman,
forHuman: message.deletedAt ? "Message deleted" : message.body.forHuman,
forAgent: message.body.forAgent || "",
...(message.body.source ? { source: message.body.source } : {}),
...(message.body.relayType ? { type: message.body.relayType } : {}),
...(message.plaintext.conversation?.replyToMessageId ? { inReplyToRelayId: message.plaintext.conversation.replyToMessageId } : {}),
threadId: message.plaintext.conversation?.threadId || message.eventId,
groupSendId: message.eventId,
hasAttachments: (message.body.attachmentMetadata || []).length > 0,
attachments: (message.body.attachmentMetadata || []).map((attachment) => ({
id: attachment.id,
name: attachment.name,
contentType: attachment.contentType,
bytes: attachment.bytes,
sha256: attachment.sha256,
openUrl: attachment.downloadUrl || `relay-e2ee://attachment/${message.eventId}/${attachment.id}`,
})),
reactions: { aggregates: [...aggregates.values()], events: [] },
direction: inbound ? "inbound" : "outbound",
...(message.plaintext.historyImport ? { historyImported: true } : {}),
readReceipts,
e2ee: { protocol: "mls10", cipherSuite: "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", senderDeviceId: message.plaintext.senderDeviceId },
};
return { ...message, item };
});
}
export async function e2eeGroupOpenedRecords(client) {
const identity = identityOrThrow();
const records = project(await syncE2eeGroupProduct(client), identity);
const taskIds = records.filter((record) => record.item.kind === "task").map((record) => record.eventId);
if (!taskIds.length) return records;
const claims = (await client.e2eeGroupTaskClaims(taskIds)).claims || {};
return records.map((record) => {
const taskClaim = claims[record.eventId];
if (!taskClaim || record.item.kind !== "task") return record;
const taskState = taskClaim.workState === "completed"
? "completed"
: taskClaim.workState === "working" ? "started" : "requested";
return {
...record,
item: {
...record.item,
taskClaim,
taskState,
...(taskClaim.workStartedAt ? { taskStartedAt: taskClaim.workStartedAt } : {}),
...(taskClaim.workCompletedAt ? { taskCompletedAt: taskClaim.workCompletedAt } : {}),
recipientGroupId: taskClaim.channel?.id || record.groupId,
recipientGroupName: taskClaim.channel?.name || "Channel",
},
};
});
}
function participantId(value) {
return `e2eeparty_${createHash("sha256").update(`relay-e2ee-chat-participant-v1${value}`).digest("hex").slice(0, 24)}`;
}
export async function e2eeGroupChats(client) {
const identity = identityOrThrow();
const [records, response] = await Promise.all([e2eeGroupOpenedRecords(client), client.groups()]);
const groups = response.groups || [];
return groups.map((group) => {
const messages = records.filter((record) => record.groupId === group.id)
.sort((a, b) => String(a.item.createdAt).localeCompare(String(b.item.createdAt)));
const newest = messages.at(-1)?.item;
const participants = [
{ id: participantId(identity.userId), name: "You", self: true },
...(group.owner && group.owner.userId !== identity.userId
? [{ id: participantId(group.owner.userId), name: group.owner.name || "Group owner", self: false }]
: []),
...group.members
.filter((member) => member.email !== group.owner?.email)
.map((member) => ({ id: participantId(member.contactId), name: member.name, self: false })),
];
return {
chatId: group.id,
title: group.name,
kind: "group",
participants,
group: {
groupId: group.id,
name: group.name,
owned: Boolean(group.owned),
archived: Boolean(group.archivedAt),
membershipStatus: group.owned ? "owner" : "active",
canPost: !group.archivedAt,
},
threadIds: [...new Set(messages.map((record) => record.item.threadId || record.eventId))],
messageCount: messages.length,
unreadCount: messages.filter((record) => record.item.direction === "inbound" && record.item.state === "delivered").length,
...(newest ? { lastMessage: {
relayId: newest.relayId,
...(newest.title ? { title: newest.title } : {}),
preview: newest.preview,
senderName: newest.direction === "outbound" ? "You" : newest.sender.name,
createdAt: newest.createdAt,
direction: newest.direction,
} } : {}),
updatedAt: newest?.createdAt || group.updatedAt || group.createdAt,
items: messages.map((record) => record.item),
hasMoreMessages: false,
};
}).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
}
export async function e2eeGroupChat(client, groupId) {
return (await e2eeGroupChats(client)).find((chat) => chat.chatId === groupId);
}
export async function e2eeGroupChatForThread(client, threadId) {
return (await e2eeGroupChats(client)).find((chat) =>
chat.items.some((item) => item.threadId === threadId || item.relayId === threadId));
}
export async function e2eeGroupPacket(record, groupName = "Relay group") {
const materialized = await materializeE2eeAttachments(record.eventId, record.body.attachmentMetadata || []);
return {
packet: {
schemaVersion: 3,
id: record.eventId,
relayId: record.eventId,
createdAt: record.item.createdAt,
kind: record.item.kind,
...(record.item.type ? { type: record.item.type } : {}),
...(record.item.title ? { title: record.item.title, displayTitle: record.item.title } : {}),
forHuman: record.item.forHuman,
forAgent: record.item.forAgent,
source: record.item.source || { host: "relay-companion" },
targetSurfaces: record.body.targetSurfaces || [],
sender: record.item.sender,
recipient: { name: groupName },
...(record.item.inReplyToRelayId ? { inReplyToRelayId: record.item.inReplyToRelayId } : {}),
threadId: record.item.threadId,
groupSendId: record.eventId,
...(record.item.recipientGroupId ? { recipientGroupId: record.item.recipientGroupId } : {}),
...(record.item.recipientGroupName ? { recipientGroupName: record.item.recipientGroupName } : {}),
...(record.item.taskState ? { taskState: record.item.taskState } : {}),
...(record.item.taskStartedAt ? { taskStartedAt: record.item.taskStartedAt } : {}),
...(record.item.taskCompletedAt ? { taskCompletedAt: record.item.taskCompletedAt } : {}),
...(record.item.taskClaim ? { taskClaim: record.item.taskClaim } : {}),
reactions: record.item.reactions,
attachments: materialized,
e2ee: record.item.e2ee,
...(record.item.historyImported ? { historyImported: true } : {}),
},
attachmentUrls: {},
};
}
export const E2EE_GROUP_PRODUCT_EVENTS = {
edit: PRODUCT_EDIT,
delete: PRODUCT_DELETE,
reaction: PRODUCT_REACTION,
receipt: PRODUCT_RECEIPT,
};