UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

192 lines (178 loc) • 7.38 kB
import fs from "node:fs"; import path from "node:path"; import { createHash, randomBytes } from "node:crypto"; import atomicJson from "./atomic-json.cjs"; import e2eeIdentity from "./e2ee-identity.cjs"; const { atomicWriteJsonSync } = atomicJson; const { identityPath, readPairedIdentity } = e2eeIdentity; const CHECKPOINT_VERSION = 1; const MAX_EXPORT_PAGES = 10_000; export function e2eeHistoryImportCheckpointPath(options = {}) { return path.join(path.dirname(identityPath(options)), "e2ee-history-import-checkpoint.json"); } function newCheckpoint(accountId) { return { version: CHECKPOINT_VERSION, accountId, salt: randomBytes(32).toString("base64url"), entries: {}, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; } function readCheckpoint(accountId, options = {}) { const filePath = e2eeHistoryImportCheckpointPath(options); if (!fs.existsSync(filePath)) return { filePath, checkpoint: newCheckpoint(accountId) }; let checkpoint; try { checkpoint = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { throw new Error("The local E2EE history import checkpoint is unreadable. Move it aside and begin a new dry run."); } if ( checkpoint?.version !== CHECKPOINT_VERSION || checkpoint.accountId !== accountId || typeof checkpoint.salt !== "string" || !checkpoint.entries || typeof checkpoint.entries !== "object" ) { throw new Error("The local E2EE history import checkpoint belongs to another account or format."); } return { filePath, checkpoint }; } function writeCheckpoint(filePath, checkpoint) { checkpoint.updatedAt = new Date().toISOString(); atomicWriteJsonSync(filePath, checkpoint, { mode: 0o600 }); } function sourceKey(checkpoint, sourceId) { return createHash("sha256") .update("relay-e2ee-history-import-checkpoint-v1") .update(checkpoint.salt) .update(String(sourceId)) .digest("base64url"); } export async function loadLegacyHistoryForImport(client, { pageSize = 50 } = {}) { const items = []; const seenCursors = new Set(); let cursor; for (let page = 0; page < MAX_EXPORT_PAGES; page += 1) { const response = await client.e2eeExportLegacyHistory({ ...(cursor ? { cursor } : {}), limit: pageSize, }); if (!Array.isArray(response?.items)) throw new Error("Relay returned an invalid history import page."); items.push(...response.items); if (!response.nextCursor) return items; if (seenCursors.has(response.nextCursor)) throw new Error("Relay repeated a history import cursor."); seenCursors.add(response.nextCursor); cursor = response.nextCursor; } throw new Error("History import exceeded the bounded page limit."); } export function summarizeLegacyHistory(items) { return (items || []).reduce((summary, item) => { summary.messages += 1; if (item.recipient?.groupId) summary.groups += 1; else summary.direct += 1; if (item.kind === "task") summary.requests += 1; summary.attachments += Array.isArray(item.attachments) ? item.attachments.length : 0; if (item.edited) summary.edited += 1; if (item.deleted) summary.deleted += 1; return summary; }, { messages: 0, direct: 0, groups: 0, requests: 0, attachments: 0, edited: 0, deleted: 0 }); } async function downloadAttachment(attachment, { fetchImpl = fetch } = {}) { const response = await fetchImpl(attachment.downloadUrl, { signal: AbortSignal.timeout(60_000) }); if (!response.ok) throw new Error(`Attachment download failed with HTTP ${response.status}.`); const body = Buffer.from(await response.arrayBuffer()); if (body.length !== Number(attachment.bytes)) { throw new Error(`Attachment ${attachment.name} did not match its legacy size.`); } const sha256 = createHash("sha256").update(body).digest("hex"); if (attachment.sha256 && sha256 !== String(attachment.sha256).toLowerCase()) { throw new Error(`Attachment ${attachment.name} did not match its legacy SHA-256 hash.`); } return { name: attachment.name, contentType: attachment.contentType || "application/octet-stream", bytes: body.length, sha256, contentBase64: body.toString("base64"), }; } function newIdempotencyKey() { return `history-import:${randomBytes(24).toString("base64url")}`; } export async function importLegacyHistory(client, items, { accountId, maxItems = Number.POSITIVE_INFINITY, checkpointOptions = {}, fetchImpl = fetch, onProgress = () => {}, } = {}) { if (!accountId) throw new Error("History import requires the authenticated Relay account id."); const { filePath, checkpoint } = readCheckpoint(accountId, checkpointOptions); const result = { imported: 0, alreadyImported: 0, remaining: 0, checkpointPath: filePath }; let attempted = 0; for (const [index, item] of (items || []).entries()) { const key = sourceKey(checkpoint, item.sourceId); const existing = checkpoint.entries[key]; if (existing?.status === "completed") { result.alreadyImported += 1; continue; } if (attempted >= maxItems) { result.remaining += 1; continue; } attempted += 1; const entry = existing || { status: "pending", idempotencyKey: newIdempotencyKey(), createdAt: new Date().toISOString(), }; checkpoint.entries[key] = entry; // Persist the random destination idempotency key before network I/O. If the // process dies after delivery, the next run recovers the exact E2EE outbox. writeCheckpoint(filePath, checkpoint); const attachments = []; for (const attachment of item.attachments || []) { attachments.push(await downloadAttachment(attachment, { fetchImpl })); } const sent = await client.sendRelay({ recipient: item.recipient, kind: item.kind || "message", ...(item.title ? { title: item.title } : {}), forHuman: item.forHuman, forAgent: item.forAgent || "", ...(item.type ? { type: item.type } : {}), targetSurfaces: item.targetSurfaces || [], ...(item.source ? { source: item.source } : {}), attachments, idempotencyKey: entry.idempotencyKey, historyImport: true, historyImportEdited: item.edited === true, historyImportDeleted: item.deleted === true, ...(item.kind === "task" ? { historyImportTaskState: item.taskState || "requested" } : {}), }); entry.status = "completed"; entry.destinationId = sent.relayId; entry.completedAt = new Date().toISOString(); writeCheckpoint(filePath, checkpoint); result.imported += 1; onProgress({ index: index + 1, total: items.length, destinationId: sent.relayId }); } return result; } export async function prepareE2eeHistoryImport(client, options = {}) { const [me, status] = await Promise.all([client.me(), client.e2eeStatus()]); const identity = readPairedIdentity(options.checkpointOptions); if (!identity || identity.userId !== me.user?.id) { throw new Error("This machine does not hold the signed-in account's E2EE identity. Re-pair it before importing history."); } if (status.mode !== "required") { throw new Error("History import runs only against an E2EE-required Relay environment."); } const items = await loadLegacyHistoryForImport(client, options); return { me, status, items, summary: summarizeLegacyHistory(items) }; }