From fd955204009d44fddb3b5e6e314fecaeae010c44 Mon Sep 17 00:00:00 2001 From: Other Brother Darryl Date: Thu, 13 Aug 2026 18:01:26 -0400 Subject: [PATCH] feat(desktop): add section workspace migration Signed-off-by: Other Brother Darryl --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/workspace_crypto.rs | 174 +++ desktop/src-tauri/src/lib.rs | 3 + .../sidebar/lib/channelSectionsSync.ts | 26 + .../sidebar/lib/sectionWorkspace.test.mjs | 276 +++++ .../features/sidebar/lib/sectionWorkspace.ts | 998 ++++++++++++++++++ .../sidebar/lib/sectionWorkspaceKinds.ts | 2 + .../sidebar/lib/useChannelSections.ts | 90 +- desktop/src/shared/api/workspaceCrypto.ts | 21 + 11 files changed, 1589 insertions(+), 5 deletions(-) create mode 100644 desktop/src-tauri/src/commands/workspace_crypto.rs create mode 100644 desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs create mode 100644 desktop/src/features/sidebar/lib/sectionWorkspace.ts create mode 100644 desktop/src/features/sidebar/lib/sectionWorkspaceKinds.ts create mode 100644 desktop/src/shared/api/workspaceCrypto.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7fa6c4cb7..6ca1fe4a9 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1083,6 +1083,7 @@ dependencies = [ name = "buzz-desktop" version = "0.5.11" dependencies = [ + "aes-gcm", "anyhow", "arboard", "atomic-write-file", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 546764587..be6fbe0ba 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -69,6 +69,7 @@ user-idle = { version = "0.6", default-features = false } [dependencies] atomic-write-file = "0.3" +aes-gcm = "0.10" anyhow = "1" dirs = "6" tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 524737164..20a9f1bb7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -66,6 +66,7 @@ mod window_chrome; mod window_vibrancy; mod workflows; mod workspace; +mod workspace_crypto; pub use agent_access::*; pub use agent_auth::*; @@ -119,3 +120,4 @@ pub use window_chrome::*; pub use window_vibrancy::*; pub use workflows::*; pub use workspace::*; +pub use workspace_crypto::*; diff --git a/desktop/src-tauri/src/commands/workspace_crypto.rs b/desktop/src-tauri/src/commands/workspace_crypto.rs new file mode 100644 index 000000000..104db7474 --- /dev/null +++ b/desktop/src-tauri/src/commands/workspace_crypto.rs @@ -0,0 +1,174 @@ +//! NIP-SW AES-256-GCM metadata primitives. +//! +//! Keys and plaintext cross this boundary only for the duration of the +//! operation. The wire envelope is the frozen `aes256gcm::` +//! encoding from NIP-SW. + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use getrandom::getrandom; + +const KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 12; +const TAG_BYTES: usize = 16; +const PREFIX: &str = "aes256gcm"; + +fn decode_key(key_hex: &str) -> Result<[u8; KEY_BYTES], String> { + let bytes = + hex::decode(key_hex).map_err(|_| "workspace key must be lowercase hex".to_string())?; + if bytes.len() != KEY_BYTES + || key_hex.len() != KEY_BYTES * 2 + || key_hex + .chars() + .any(|c| !c.is_ascii_hexdigit() || c.is_ascii_uppercase()) + { + return Err("workspace key must be exactly 32 bytes of lowercase hex".into()); + } + let mut key = [0u8; KEY_BYTES]; + key.copy_from_slice(&bytes); + Ok(key) +} + +fn cipher(key_hex: &str) -> Result { + let key = decode_key(key_hex)?; + Aes256Gcm::new_from_slice(&key).map_err(|_| "invalid workspace key".into()) +} + +fn encrypt_with_nonce( + key_hex: &str, + plaintext: &[u8], + aad: &[u8], + nonce: &[u8; NONCE_BYTES], +) -> Result { + let cipher = cipher(key_hex)?; + let encrypted = cipher + .encrypt( + Nonce::from_slice(nonce), + aes_gcm::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| "workspace metadata encryption failed".to_string())?; + Ok(format!( + "{PREFIX}:{}:{}", + URL_SAFE_NO_PAD.encode(nonce), + URL_SAFE_NO_PAD.encode(encrypted), + )) +} + +fn parse_envelope(envelope: &str) -> Result<([u8; NONCE_BYTES], Vec), String> { + let parts = envelope.split(':').collect::>(); + if parts.len() != 3 || parts[0] != PREFIX { + return Err("invalid workspace metadata envelope".into()); + } + let nonce_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| "invalid workspace metadata nonce".to_string())?; + if nonce_bytes.len() != NONCE_BYTES { + return Err("workspace metadata nonce must be exactly 12 bytes".into()); + } + let ciphertext = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| "invalid workspace metadata ciphertext".to_string())?; + if ciphertext.len() < TAG_BYTES { + return Err("workspace metadata ciphertext is too short".into()); + } + let mut nonce = [0u8; NONCE_BYTES]; + nonce.copy_from_slice(&nonce_bytes); + Ok((nonce, ciphertext)) +} + +fn decrypt_inner(key_hex: &str, envelope: &str, aad: &[u8]) -> Result { + let (nonce, ciphertext) = parse_envelope(envelope)?; + let cipher = cipher(key_hex)?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(&nonce), + aes_gcm::aead::Payload { + msg: &ciphertext, + aad, + }, + ) + .map_err(|_| "workspace metadata authentication failed".to_string())?; + String::from_utf8(plaintext).map_err(|_| "workspace metadata is not valid UTF-8".into()) +} + +pub(crate) fn generate_key_hex() -> Result { + let mut key = [0u8; KEY_BYTES]; + getrandom(&mut key).map_err(|_| "workspace key generation failed".to_string())?; + Ok(hex::encode(key)) +} + +#[tauri::command] +pub fn generate_workspace_key() -> Result { + generate_key_hex() +} + +#[tauri::command] +pub fn encrypt_workspace_metadata( + key_hex: String, + plaintext: String, + aad: String, +) -> Result { + let mut nonce = [0u8; NONCE_BYTES]; + getrandom(&mut nonce).map_err(|_| "workspace metadata nonce generation failed".to_string())?; + encrypt_with_nonce(&key_hex, plaintext.as_bytes(), aad.as_bytes(), &nonce) +} + +#[tauri::command] +pub fn decrypt_workspace_metadata( + key_hex: String, + envelope: String, + aad: String, +) -> Result { + decrypt_inner(&key_hex, &envelope, aad.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + const AAD: &[u8] = b"{\"version\":1}"; + + #[test] + fn round_trip_matches_frozen_encoding() { + let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + let aad = b"{\"community\":\"relay.example\",\"key_epoch\":1,\"owner_pubkey\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"purpose\":\"label\",\"section_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\",\"version\":1}"; + let envelope = encrypt_with_nonce(KEY, b"Alpha", aad, &nonce).unwrap(); + assert_eq!( + envelope, + "aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7" + ); + assert_eq!(decrypt_inner(KEY, &envelope, aad).unwrap(), "Alpha"); + } + + #[test] + fn wrong_aad_and_tampering_fail_authentication() { + let nonce = [0u8; NONCE_BYTES]; + let envelope = encrypt_with_nonce(KEY, b"Alpha", AAD, &nonce).unwrap(); + assert_eq!( + decrypt_inner(KEY, &envelope, b"wrong"), + Err("workspace metadata authentication failed".into()) + ); + let mut tampered = envelope.into_bytes(); + let last = tampered.len() - 1; + tampered[last] = if tampered[last] == b'A' { b'B' } else { b'A' }; + assert_eq!( + decrypt_inner(KEY, std::str::from_utf8(&tampered).unwrap(), AAD), + Err("workspace metadata authentication failed".into()) + ); + } + + #[test] + fn malformed_key_nonce_and_envelope_fail_closed() { + assert!(decode_key("00").is_err()); + assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQoL").is_err()); + assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQoL:not-base64!").is_err()); + assert!(parse_envelope("aes256gcm:AAECAwQFBgcICQo:AA==").is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8..1523ad0cd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -688,6 +688,9 @@ pub fn run() { create_auth_event, nip44_encrypt_to_self, nip44_decrypt_from_self, + generate_workspace_key, + encrypt_workspace_metadata, + decrypt_workspace_metadata, get_channels, create_channel, ensure_starter_channels, diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 858b62430..399c108d7 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -28,6 +28,12 @@ export type RemoteSections = { eventId: string; }; +export type LegacySectionSource = { + event: RelayEvent; + plaintext: string; + store: ChannelSectionStore; +}; + async function decryptAndParse( event: RelayEvent, ): Promise { @@ -58,6 +64,26 @@ export class ChannelSectionSyncManager { this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl); } + async fetchLegacySource(): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_CHANNEL_SECTIONS], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0 || events[0].pubkey !== this.pubkey) return null; + const event = events[0]; + this.recordRemoteHead(event.created_at); + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parseChannelSectionPayload(JSON.parse(plaintext)); + if (!store) return null; + return { event, plaintext, store }; + } catch { + return null; + } + } + async fetchRemoteSections(): Promise> { try { const events = await relayClient.fetchEvents({ diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs new file mode 100644 index 000000000..360ab3484 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.test.mjs @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { finalizeEvent } from "nostr-tools/pure"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + canonicalJson, + parseSectionWorkspaceProjection, + parseStrictJson, + projectionRevisionAction, + SectionWorkspaceSyncManager, +} from "./sectionWorkspace.ts"; + +const fixture = JSON.parse( + readFileSync( + new URL("../../../../../docs/nips/NIP-SW.fixtures.json", import.meta.url), + "utf8", + ), +); +const OWNER_BYTES = new Uint8Array(32).fill(1); +const OWNER = + "1111111111111111111111111111111111111111111111111111111111111111"; +const RELAY = "wss://Relay.Example/"; +const PROJECTION_EVENT = { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + pubkey: "9999999999999999999999999999999999999999999999999999999999999999", + created_at: 1, + kind: 30623, + tags: [ + ["d", OWNER], + ["p", OWNER], + ], + content: "", + sig: "signature", +}; + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + }; +} + +function installWindow(storage, invoke) { + const previous = globalThis.window; + globalThis.window = { + localStorage: storage, + __TAURI_INTERNALS__: { invoke }, + setTimeout, + clearTimeout, + }; + return () => { + globalThis.window = previous; + }; +} + +function installRelayStubs({ fetchEvents, publishEvent, subscribeLive }) { + const originals = { + fetchEvents: relayClient.fetchEvents, + publishEvent: relayClient.publishEvent, + subscribeLive: relayClient.subscribeLive, + }; + relayClient.fetchEvents = fetchEvents; + relayClient.publishEvent = publishEvent; + relayClient.subscribeLive = subscribeLive; + return () => Object.assign(relayClient, originals); +} + +function makeProjectionEvent( + projection = fixture.projection_cases[0].projection, +) { + const template = { + ...PROJECTION_EVENT, + pubkey: OWNER, + tags: [ + ["d", OWNER], + ["p", OWNER], + ], + content: JSON.stringify(projection), + }; + const signed = finalizeEvent(template, OWNER_BYTES); + return { + ...template, + ...signed, + tags: template.tags, + content: template.content, + }; +} + +test("section workspace consumes every shared projection fixture revision case", () => { + const accepted = parseSectionWorkspaceProjection( + fixture.projection_cases[0].projection, + ); + assert.equal(accepted.sections.length, 2); + assert.equal(accepted.assignments.length, 1); + for (const item of fixture.projection_cases) { + assert.equal( + projectionRevisionAction( + item.previous_revision ?? null, + item.projection.revision, + ), + item.expect, + item.name, + ); + } +}); + +test("section workspace rejects unknown fields, unsupported versions, duplicate IDs, and malformed JSON", () => { + const valid = structuredClone(fixture.projection_cases[0].projection); + assert.throws(() => + parseSectionWorkspaceProjection({ ...valid, extra: true }), + ); + assert.throws(() => + parseSectionWorkspaceProjection({ ...valid, version: 2 }), + ); + const duplicate = structuredClone(valid); + duplicate.sections[1].id = duplicate.sections[0].id; + assert.throws(() => parseSectionWorkspaceProjection(duplicate)); + assert.throws(() => parseStrictJson('{"version":1,"version":1}')); +}); + +test("section workspace canonicalization matches the frozen legacy hash vector", () => { + const vector = fixture.canonicalization.legacy_plaintext; + const canonical = canonicalJson(vector.input); + assert.equal(canonical, vector.canonical); + assert.equal( + createHash("sha256").update(canonical).digest("hex"), + vector.sha256, + ); +}); + +test("projection is decrypted, cached by normalized relay, and rendered during outage", async () => { + const storage = memoryStorage(); + let fetchCount = 0; + const restoreRelay = installRelayStubs({ + fetchEvents: async () => { + fetchCount += 1; + return fetchCount === 1 + ? [makeProjectionEvent()] + : Promise.reject(new Error("offline")); + }, + publishEvent: async () => {}, + subscribeLive: async () => async () => {}, + }); + const restoreWindow = installWindow(storage, async (command, args) => { + if (command === "nip44_decrypt_from_self") return "workspace-key"; + if (command === "decrypt_workspace_metadata") { + if (args.envelope === "ciphertext-alpha") return "Alpha"; + if (args.envelope === "ciphertext-beta") return "Beta"; + if (args.envelope === "ciphertext-icon") return "folder"; + } + throw new Error(`unexpected command ${command}`); + }); + try { + const manager = new SectionWorkspaceSyncManager(OWNER, RELAY); + const first = await manager.bootstrap(async () => null); + assert.deepEqual(first, { + version: 1, + sections: [ + { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "Alpha", order: 0 }, + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + name: "Beta", + icon: "folder", + order: 1, + }, + ], + assignments: { + "cccccccc-cccc-4ccc-8ccc-cccccccccccc": + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + }); + assert.ok(manager.isCanonical()); + + const cached = new SectionWorkspaceSyncManager(OWNER, RELAY); + assert.deepEqual(cached.getCachedStore(), first); + const offline = await cached.bootstrap(async () => null); + assert.deepEqual(offline, first); + } finally { + restoreWindow(); + restoreRelay(); + } +}); + +test("migration stores one canonical command and replays exact bytes after a failed publish", async () => { + const storage = memoryStorage(); + const legacyStore = { + version: 1, + sections: [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + name: "Alpha", + icon: "folder", + order: 0, + }, + ], + assignments: { + "cccccccc-cccc-4ccc-8ccc-cccccccccccc": + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + }; + const legacyPlaintext = JSON.stringify({ + version: 1, + sections: legacyStore.sections, + assignments: legacyStore.assignments, + }); + const legacyEvent = { + ...PROJECTION_EVENT, + id: "2222222222222222222222222222222222222222222222222222222222222222", + pubkey: OWNER, + kind: 30078, + content: "legacy-ciphertext", + tags: [["d", "channel-sections"]], + }; + const published = []; + let shouldFail = true; + const restoreRelay = installRelayStubs({ + fetchEvents: async () => [], + publishEvent: async (event) => { + published.push(event); + if (shouldFail) { + shouldFail = false; + throw new Error("offline"); + } + return event; + }, + subscribeLive: async () => async () => {}, + }); + const restoreWindow = installWindow(storage, async (command, args) => { + if (command === "generate_workspace_key") + return "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + if (command === "encrypt_workspace_metadata") + return `aes256gcm:${args.aad}:ciphertext`; + if (command === "nip44_encrypt_to_self") return "nip44-owner-envelope"; + if (command === "sign_event") + return JSON.stringify({ + ...legacyEvent, + id: `event-${published.length}`, + kind: args.kind, + content: args.content, + tags: args.tags, + }); + throw new Error(`unexpected command ${command}`); + }); + try { + const manager = new SectionWorkspaceSyncManager(OWNER, RELAY); + await manager.bootstrap(async () => ({ + event: legacyEvent, + plaintext: legacyPlaintext, + store: legacyStore, + })); + assert.equal(published.length, 1); + assert.ok(manager.isCanonical()); + const firstCommand = published[0].content; + const firstTags = published[0].tags; + + const retryManager = new SectionWorkspaceSyncManager(OWNER, RELAY); + await retryManager.bootstrap(async () => { + throw new Error( + "legacy retrieval must not be used after migration starts", + ); + }); + assert.equal(published.length, 2); + assert.equal(published[1].content, firstCommand); + assert.deepEqual(published[1].tags, firstTags); + } finally { + restoreWindow(); + restoreRelay(); + } +}); diff --git a/desktop/src/features/sidebar/lib/sectionWorkspace.ts b/desktop/src/features/sidebar/lib/sectionWorkspace.ts new file mode 100644 index 000000000..52e8f12f7 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sectionWorkspace.ts @@ -0,0 +1,998 @@ +import { bytesToHex } from "@noble/hashes/utils.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { verifyEvent } from "nostr-tools/pure"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import { + generateWorkspaceKey, + encryptWorkspaceMetadata, + decryptWorkspaceMetadata, +} from "@/shared/api/workspaceCrypto"; + +import type { RelayEvent } from "@/shared/api/types"; +import type { LegacySectionSource } from "./channelSectionsSync"; +import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; +import { + KIND_SECTION_WORKSPACE_IMPORT, + KIND_SECTION_WORKSPACE_PROJECTION, +} from "./sectionWorkspaceKinds"; +import type { + ChannelSectionStore, + ChannelSection, +} from "./channelSectionsStorage"; +import { + boundChannelSectionsStore, + parseChannelSectionPayload, +} from "./channelSectionsStorage"; + +export const SECTION_WORKSPACE_VERSION = 1; +const MAX_SECTIONS = 100; +const MAX_ASSIGNMENTS = 1_000; +const MAX_ENCRYPTED_METADATA_BYTES = 65_535; +const MAX_KEY_ENVELOPE_BYTES = 4_096; +const CACHE_PREFIX = "buzz-section-workspace.v1"; +const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`; +const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`; + +type ProjectionSection = { + id: string; + rank: number; + encrypted_label: string; + encrypted_icon: string | null; +}; +type ProjectionAssignment = { + channel_id: string; + section_id: string; + revision: number; +}; +export type SectionWorkspaceProjection = { + version: 1; + owner_pubkey: string; + revision: number; + layout_revision: number; + key_epoch: number; + migration: { source_event_id: string; source_hash: string }; + reader_key_envelope: string; + sections: ProjectionSection[]; + assignments: ProjectionAssignment[]; +}; +export type WorkspaceCache = { + projection: SectionWorkspaceProjection; + store: ChannelSectionStore; + revision: number; + keyEpoch: number; +}; + +type ImportSection = { + id: string; + rank: number; + encrypted_label: string; + encrypted_icon: string | null; +}; +type ImportAssignment = { channel_id: string; section_id: string }; +type WorkspaceImport = { + version: 1; + action_id: string; + source_event_id: string; + source_hash: string; + key_epoch: 1; + owner_key_envelope: string; + sections: ImportSection[]; + assignments: ImportAssignment[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function rejectDuplicateJsonKeys(input: string): void { + let index = 0; + const skipWhitespace = () => { + while (/\s/.test(input[index] ?? "")) index += 1; + }; + const parseString = (): string => { + const start = index; + if (input[index] !== '"') throw new Error("invalid JSON"); + index += 1; + while (index < input.length) { + const character = input[index++]; + if (character === "\\") { + index += 1; + } else if (character === '"') { + return JSON.parse(input.slice(start, index)) as string; + } + } + throw new Error("invalid JSON string"); + }; + const parseValue = (): void => { + skipWhitespace(); + const character = input[index]; + if (character === "{") { + index += 1; + skipWhitespace(); + const keys = new Set(); + if (input[index] === "}") { + index += 1; + return; + } + while (index < input.length) { + skipWhitespace(); + const key = parseString(); + if (keys.has(key)) throw new Error("duplicate JSON key"); + keys.add(key); + skipWhitespace(); + if (input[index++] !== ":") throw new Error("invalid JSON object"); + parseValue(); + skipWhitespace(); + if (input[index] === "}") { + index += 1; + return; + } + if (input[index++] !== ",") throw new Error("invalid JSON object"); + } + throw new Error("invalid JSON object"); + } + if (character === "[") { + index += 1; + skipWhitespace(); + if (input[index] === "]") { + index += 1; + return; + } + while (index < input.length) { + parseValue(); + skipWhitespace(); + if (input[index] === "]") { + index += 1; + return; + } + if (input[index++] !== ",") throw new Error("invalid JSON array"); + } + throw new Error("invalid JSON array"); + } + if (character === '"') { + parseString(); + return; + } + const start = index; + while (index < input.length && !/[\s,\]}]/.test(input[index] ?? "")) + index += 1; + if (start === index) throw new Error("invalid JSON value"); + }; + parseValue(); + skipWhitespace(); + if (index !== input.length) throw new Error("invalid JSON trailing data"); +} + +export function parseStrictJson(input: string): unknown { + rejectDuplicateJsonKeys(input); + return JSON.parse(input) as unknown; +} + +function exactKeys( + value: Record, + keys: readonly string[], +): boolean { + const expected = new Set(keys); + return ( + Object.keys(value).length === expected.size && + Object.keys(value).every((key) => expected.has(key)) + ); +} +function integerField(value: Record, key: string): number { + const result = value[key]; + if (typeof result !== "number" || !Number.isSafeInteger(result) || result < 0) + throw new Error(`invalid ${key}`); + return result; +} +function uuid(value: unknown, key: string): string { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value, + ) + ) + throw new Error(`invalid ${key}`); + return value; +} +function lowercaseHex64(value: unknown, key: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) + throw new Error(`invalid ${key}`); + return value; +} +function positiveIntegerField( + value: Record, + key: string, +): number { + const result = integerField(value, key); + if (result < 1) throw new Error(`invalid ${key}`); + return result; +} +function boundedUtf8String( + value: unknown, + key: string, + maxBytes: number, +): string { + if (typeof value !== "string" || value.length === 0) + throw new Error(`invalid ${key}`); + if (new TextEncoder().encode(value).byteLength > maxBytes) + throw new Error(`${key} exceeds size limit`); + return value; +} + +export function parseSectionWorkspaceProjection( + input: unknown, +): SectionWorkspaceProjection { + if ( + !isRecord(input) || + !exactKeys(input, [ + "version", + "owner_pubkey", + "revision", + "layout_revision", + "key_epoch", + "migration", + "reader_key_envelope", + "sections", + "assignments", + ]) + ) + throw new Error("invalid projection fields"); + if (input.version !== SECTION_WORKSPACE_VERSION) + throw new Error("unsupported projection version"); + const migration = input.migration; + if ( + !isRecord(migration) || + !exactKeys(migration, ["source_event_id", "source_hash"]) + ) + throw new Error("invalid migration marker"); + const sectionsInput = input.sections; + const assignmentsInput = input.assignments; + if ( + !Array.isArray(sectionsInput) || + sectionsInput.length > MAX_SECTIONS || + !Array.isArray(assignmentsInput) || + assignmentsInput.length > MAX_ASSIGNMENTS + ) + throw new Error("projection limits exceeded"); + const sections = sectionsInput.map((raw, index) => { + if ( + !isRecord(raw) || + !exactKeys(raw, ["id", "rank", "encrypted_label", "encrypted_icon"]) + ) + throw new Error(`invalid section ${index}`); + const icon = raw.encrypted_icon; + if (icon !== null && typeof icon !== "string") + throw new Error("invalid encrypted_icon"); + return { + id: uuid(raw.id, "section id"), + rank: integerField(raw, "rank"), + encrypted_label: boundedUtf8String( + raw.encrypted_label, + "encrypted_label", + MAX_ENCRYPTED_METADATA_BYTES, + ), + encrypted_icon: + icon === null + ? null + : boundedUtf8String( + icon, + "encrypted_icon", + MAX_ENCRYPTED_METADATA_BYTES, + ), + }; + }); + const ids = new Set(sections.map((section) => section.id)); + if (ids.size !== sections.length) throw new Error("duplicate section id"); + const ranks = new Set(sections.map((section) => section.rank)); + if ( + ranks.size !== sections.length || + sections.some((section) => section.rank >= sections.length) + ) + throw new Error("invalid section ranks"); + const assignments = assignmentsInput.map((raw, index) => { + if ( + !isRecord(raw) || + !exactKeys(raw, ["channel_id", "section_id", "revision"]) + ) + throw new Error(`invalid assignment ${index}`); + const assignment = { + channel_id: uuid(raw.channel_id, "channel id"), + section_id: uuid(raw.section_id, "section id"), + revision: integerField(raw, "revision"), + }; + if (!ids.has(assignment.section_id)) + throw new Error("assignment references unknown section"); + return assignment; + }); + if ( + new Set(assignments.map((assignment) => assignment.channel_id)).size !== + assignments.length + ) + throw new Error("duplicate channel assignment"); + return { + version: 1, + owner_pubkey: lowercaseHex64(input.owner_pubkey, "owner_pubkey"), + revision: integerField(input, "revision"), + layout_revision: integerField(input, "layout_revision"), + key_epoch: positiveIntegerField(input, "key_epoch"), + migration: { + source_event_id: lowercaseHex64( + migration.source_event_id, + "source_event_id", + ), + source_hash: lowercaseHex64(migration.source_hash, "source_hash"), + }, + reader_key_envelope: boundedUtf8String( + input.reader_key_envelope, + "reader_key_envelope", + MAX_KEY_ENVELOPE_BYTES, + ), + sections, + assignments, + }; +} +function parseWorkspaceImport(input: unknown): WorkspaceImport { + if ( + !isRecord(input) || + !exactKeys(input, [ + "version", + "action_id", + "source_event_id", + "source_hash", + "key_epoch", + "owner_key_envelope", + "sections", + "assignments", + ]) || + input.version !== SECTION_WORKSPACE_VERSION || + input.key_epoch !== 1 + ) + throw new Error("invalid workspace import fields"); + const sectionsInput = input.sections; + const assignmentsInput = input.assignments; + if ( + !Array.isArray(sectionsInput) || + sectionsInput.length > MAX_SECTIONS || + !Array.isArray(assignmentsInput) || + assignmentsInput.length > MAX_ASSIGNMENTS + ) + throw new Error("workspace import limits exceeded"); + const sections = sectionsInput.map((raw, index) => { + if ( + !isRecord(raw) || + !exactKeys(raw, ["id", "rank", "encrypted_label", "encrypted_icon"]) + ) + throw new Error(`invalid import section ${index}`); + const icon = raw.encrypted_icon; + if (icon !== null && typeof icon !== "string") + throw new Error("invalid import encrypted_icon"); + return { + id: uuid(raw.id, "section id"), + rank: integerField(raw, "rank"), + encrypted_label: boundedUtf8String( + raw.encrypted_label, + "encrypted_label", + MAX_ENCRYPTED_METADATA_BYTES, + ), + encrypted_icon: + icon === null + ? null + : boundedUtf8String( + icon, + "encrypted_icon", + MAX_ENCRYPTED_METADATA_BYTES, + ), + }; + }); + const ids = new Set(sections.map((section) => section.id)); + const ranks = new Set(sections.map((section) => section.rank)); + if ( + ids.size !== sections.length || + ranks.size !== sections.length || + sections.some((section) => section.rank >= sections.length) + ) + throw new Error("invalid import section shape"); + const assignments = assignmentsInput.map((raw, index) => { + if (!isRecord(raw) || !exactKeys(raw, ["channel_id", "section_id"])) + throw new Error(`invalid import assignment ${index}`); + const assignment = { + channel_id: uuid(raw.channel_id, "channel id"), + section_id: uuid(raw.section_id, "section id"), + }; + if (!ids.has(assignment.section_id)) + throw new Error("assignment references unknown section"); + return assignment; + }); + if ( + new Set(assignments.map((assignment) => assignment.channel_id)).size !== + assignments.length + ) + throw new Error("duplicate import channel assignment"); + return { + version: 1, + action_id: uuid(input.action_id, "action_id"), + source_event_id: lowercaseHex64(input.source_event_id, "source_event_id"), + source_hash: lowercaseHex64(input.source_hash, "source_hash"), + key_epoch: 1, + owner_key_envelope: boundedUtf8String( + input.owner_key_envelope, + "owner_key_envelope", + MAX_KEY_ENVELOPE_BYTES, + ), + sections, + assignments, + }; +} +export function projectionRevisionAction( + previous: number | null, + incoming: number, +): "accept" | "refetch" | "ignore" { + if (previous === null || incoming === previous + 1) return "accept"; + if (incoming <= (previous ?? -1)) return "ignore"; + return "refetch"; +} +function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from( + left, + (character) => character.codePointAt(0) ?? 0, + ); + const rightPoints = Array.from( + right, + (character) => character.codePointAt(0) ?? 0, + ); + for ( + let index = 0; + index < Math.min(leftPoints.length, rightPoints.length); + index++ + ) { + if (leftPoints[index] !== rightPoints[index]) + return leftPoints[index] - rightPoints[index]; + } + return leftPoints.length - rightPoints.length; +} + +export function canonicalJson(input: unknown): string { + if (input === null || typeof input === "boolean" || typeof input === "string") + return JSON.stringify(input); + if (typeof input === "number") { + if (!Number.isSafeInteger(input)) + throw new Error("canonical JSON only permits integer numbers"); + return String(input); + } + if (Array.isArray(input)) return `[${input.map(canonicalJson).join(",")}]`; + if (!isRecord(input)) throw new Error("invalid JSON value"); + return `{${Object.keys(input) + .sort(compareCodePoints) + .map((key) => `${JSON.stringify(key)}:${canonicalJson(input[key])}`) + .join(",")}}`; +} +function cacheKey(pubkey: string, relayUrl: string): string { + return `${CACHE_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} +function importActionKey(pubkey: string, relayUrl: string): string { + return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} +function importCommandKey(pubkey: string, relayUrl: string): string { + return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} +function readStoredValue(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} +function readStoredImportCommand( + pubkey: string, + relayUrl: string, +): string | null { + return readStoredValue(importCommandKey(pubkey, relayUrl)); +} +function hasImportMarker(pubkey: string, relayUrl: string): boolean { + return ( + readStoredValue(importActionKey(pubkey, relayUrl)) !== null || + readStoredImportCommand(pubkey, relayUrl) !== null + ); +} +function markImportStarted( + pubkey: string, + relayUrl: string, + actionId: string, +): void { + try { + window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId); + } catch {} +} +function writeImportState( + pubkey: string, + relayUrl: string, + actionId: string, + command: string, +): void { + try { + window.localStorage.setItem(importCommandKey(pubkey, relayUrl), command); + window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId); + } catch {} +} +function readCache(pubkey: string, relayUrl: string): WorkspaceCache | null { + try { + const raw = window.localStorage.getItem(cacheKey(pubkey, relayUrl)); + if (!raw) return null; + const parsed = parseStrictJson(raw) as { + projection: unknown; + store: unknown; + revision: number; + keyEpoch: number; + }; + const projection = parseSectionWorkspaceProjection(parsed.projection); + if ( + !projection || + !isRecord(parsed.store) || + parsed.revision !== projection.revision || + parsed.keyEpoch !== projection.key_epoch + ) + return null; + const store = parseChannelSectionPayload(parsed.store); + if (!store) return null; + return { + projection, + store, + revision: parsed.revision, + keyEpoch: parsed.keyEpoch, + }; + } catch { + return null; + } +} +function writeCache( + pubkey: string, + relayUrl: string, + cache: WorkspaceCache, + store: ChannelSectionStore, +): void { + try { + window.localStorage.setItem( + cacheKey(pubkey, relayUrl), + JSON.stringify({ ...cache, store }), + ); + } catch {} +} +function aad( + community: string, + owner: string, + sectionId: string, + epoch: number, + purpose: "label" | "icon", +): string { + return canonicalJson({ + version: 1, + community, + owner_pubkey: owner, + section_id: sectionId, + key_epoch: epoch, + purpose, + }); +} +function outputWithinLimit( + value: string, + key: string, + maxBytes: number, +): string { + if (new TextEncoder().encode(value).byteLength > maxBytes) + throw new Error(`${key} exceeds size limit`); + return value; +} + +async function encryptMetadata( + key: string, + community: string, + owner: string, + section: ChannelSection, + purpose: "label" | "icon", + value: string, +): Promise { + return outputWithinLimit( + await encryptWorkspaceMetadata({ + keyHex: key, + plaintext: value, + aad: aad(community, owner, section.id, 1, purpose), + }), + "encrypted_metadata", + MAX_ENCRYPTED_METADATA_BYTES, + ); +} +async function decryptMetadata( + key: string, + community: string, + owner: string, + sectionId: string, + epoch: number, + purpose: "label" | "icon", + value: string, +): Promise { + return decryptWorkspaceMetadata({ + keyHex: key, + envelope: value, + aad: aad(community, owner, sectionId, epoch, purpose), + }); +} +async function projectionStore( + projection: SectionWorkspaceProjection, + key: string, + community: string, +): Promise { + const sections: ChannelSection[] = []; + for (const projected of projection.sections + .slice() + .sort((left, right) => left.rank - right.rank)) { + const name = await decryptMetadata( + key, + community, + projection.owner_pubkey, + projected.id, + projection.key_epoch, + "label", + projected.encrypted_label, + ); + const icon = projected.encrypted_icon + ? await decryptMetadata( + key, + community, + projection.owner_pubkey, + projected.id, + projection.key_epoch, + "icon", + projected.encrypted_icon, + ) + : undefined; + sections.push({ + id: projected.id, + name, + order: projected.rank, + ...(icon ? { icon } : {}), + }); + } + return boundChannelSectionsStore({ + version: 1, + sections, + assignments: Object.fromEntries( + projection.assignments.map((assignment) => [ + assignment.channel_id, + assignment.section_id, + ]), + ), + }); +} + +function validateEventRouting(event: RelayEvent, owner: string): void { + const ownerTags = event.tags.filter( + (tag) => tag.length === 2 && tag[0] === "p" && tag[1] === owner, + ); + const addressTags = event.tags.filter( + (tag) => tag.length === 2 && tag[0] === "d" && tag[1] === owner, + ); + if (event.kind !== KIND_SECTION_WORKSPACE_PROJECTION) { + throw new Error("invalid projection kind"); + } + if (ownerTags.length < 1 || addressTags.length !== 1) + throw new Error("projection routing mismatch"); +} + +function projectionFromEvent( + event: RelayEvent, + owner: string, +): SectionWorkspaceProjection { + if ( + event.id.length !== 64 || + !/^[0-9a-f]{64}$/.test(event.id) || + event.pubkey.length !== 64 || + !/^[0-9a-f]{64}$/.test(event.pubkey) || + !verifyEvent({ + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + }) + ) + throw new Error("invalid projection event signature"); + const projection = parseSectionWorkspaceProjection( + parseStrictJson(event.content), + ); + if (projection.owner_pubkey !== owner) + throw new Error("invalid projection owner"); + validateEventRouting(event, owner); + return projection; +} + +async function fetchProjection( + pubkey: string, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: [KIND_SECTION_WORKSPACE_PROJECTION], + "#d": [pubkey], + "#p": [pubkey], + limit: 1, + }); + if (events.length === 0) return null; + const event = events[0]; + return projectionFromEvent(event, pubkey); +} + +async function publishImportCommand( + pubkey: string, + actionId: string, + command: string, +): Promise { + const event = await signRelayEvent({ + kind: KIND_SECTION_WORKSPACE_IMPORT, + content: command, + tags: [ + ["p", pubkey], + ["action", actionId], + ], + }); + await relayClient.publishEvent( + event, + "Timed out importing channel sections.", + "Failed to import channel sections.", + ); +} + +async function replayStoredImport( + pubkey: string, + relayUrl: string, +): Promise { + const command = readStoredImportCommand(pubkey, normalizeRelayUrl(relayUrl)); + if (!command) return false; + const parsed = parseStrictJson(command) as { action_id?: unknown }; + if (typeof parsed.action_id !== "string") + throw new Error("stored import command has no action id"); + parseWorkspaceImport(parsed); + await publishImportCommand(pubkey, parsed.action_id, command); + return true; +} + +async function submitImport( + pubkey: string, + relayUrl: string, + legacy: RelayEvent, + plaintext: string, + store: ChannelSectionStore, +): Promise { + if (legacy.id.length !== 64 || !/^[0-9a-f]{64}$/.test(legacy.id)) + throw new Error("invalid legacy source event"); + if (legacy.pubkey !== pubkey) throw new Error("invalid legacy source signer"); + const normalizedRelay = normalizeRelayUrl(relayUrl); + const canonicalPlaintext = canonicalJson(parseStrictJson(plaintext)); + const sourceHash = bytesToHex( + sha256(new TextEncoder().encode(canonicalPlaintext)), + ); + let actionId: string; + let command: string | null; + try { + command = window.localStorage.getItem( + importCommandKey(pubkey, normalizedRelay), + ); + } catch { + command = null; + } + if (command) { + const parsed = parseStrictJson(command); + const imported = parseWorkspaceImport(parsed); + actionId = imported.action_id; + } else { + actionId = + window.localStorage.getItem(importActionKey(pubkey, normalizedRelay)) ?? + crypto.randomUUID(); + markImportStarted(pubkey, normalizedRelay, actionId); + const key = await generateWorkspaceKey(); + const sections: ImportSection[] = []; + const sectionIds = new Set(); + const sectionIdByLegacyId = new Map(); + for (const [index, section] of store.sections + .slice() + .sort((left, right) => left.order - right.order) + .entries()) { + const sectionId = uuid(section.id, "section id"); + if (sectionIds.has(sectionId)) throw new Error("duplicate section id"); + sectionIds.add(sectionId); + sectionIdByLegacyId.set(section.id, sectionId); + sections.push({ + id: sectionId, + rank: index, + encrypted_label: await encryptMetadata( + key, + normalizedRelay, + pubkey, + section, + "label", + section.name, + ), + encrypted_icon: section.icon + ? await encryptMetadata( + key, + normalizedRelay, + pubkey, + section, + "icon", + section.icon, + ) + : null, + }); + } + const assignments = Object.entries(store.assignments).map( + ([channel_id, section_id]) => ({ + channel_id: uuid(channel_id, "channel id"), + section_id: + sectionIdByLegacyId.get(uuid(section_id, "section id")) ?? + (() => { + throw new Error("assignment references unknown section"); + })(), + }), + ); + const imported: WorkspaceImport = { + version: 1, + action_id: actionId, + source_event_id: legacy.id, + source_hash: sourceHash, + key_epoch: 1, + owner_key_envelope: await nip44EncryptToSelf(key), + sections, + assignments, + }; + command = canonicalJson(imported); + parseWorkspaceImport(parseStrictJson(command)); + writeImportState(pubkey, normalizedRelay, actionId, command); + } + await publishImportCommand(pubkey, actionId, command); +} + +export class SectionWorkspaceSyncManager { + private destroyed = false; + private migrationStarted: boolean; + private projection: SectionWorkspaceProjection | null; + private cached: WorkspaceCache | null; + private readonly pubkey: string; + private workspaceProbeBlocked = false; + private readonly relayUrl: string; + + constructor(pubkey: string, relayUrl: string) { + this.pubkey = pubkey; + this.relayUrl = relayUrl; + this.cached = readCache(pubkey, relayUrl); + this.projection = this.cached?.projection ?? null; + this.migrationStarted = hasImportMarker(pubkey, relayUrl); + } + getCachedStore(): ChannelSectionStore | null { + return this.cached?.store ?? null; + } + isCanonical(): boolean { + return ( + this.projection !== null || + this.migrationStarted || + this.workspaceProbeBlocked + ); + } + cancelLegacyPublish(): void { + // The legacy manager is owned by the hook; this marker lets the hook stop + // a debounced whole-blob write once the relay projection is authoritative. + this.migrationStarted = true; + } + private async acceptProjection( + projection: SectionWorkspaceProjection, + ): Promise { + this.projection = projection; + const key = await nip44DecryptFromSelf(projection.reader_key_envelope); + const store = await projectionStore( + projection, + key, + normalizeRelayUrl(this.relayUrl), + ); + this.cached = { + projection, + store, + revision: projection.revision, + keyEpoch: projection.key_epoch, + }; + writeCache(this.pubkey, this.relayUrl, this.cached, store); + return store; + } + async bootstrap( + legacyFetch: () => Promise, + ): Promise { + try { + const projection = await fetchProjection(this.pubkey); + if (projection) { + const action = projectionRevisionAction( + this.projection?.revision ?? null, + projection.revision, + ); + if (action === "ignore") return this.getCachedStore(); + if (action === "refetch") { + const refreshed = await fetchProjection(this.pubkey); + if ( + !refreshed || + refreshed.revision <= (this.projection?.revision ?? -1) + ) + return this.getCachedStore(); + return this.acceptProjection(refreshed); + } + return this.acceptProjection(projection); + } + if (this.migrationStarted) { + const replayed = await replayStoredImport(this.pubkey, this.relayUrl); + if (replayed) return this.getCachedStore(); + if (readStoredValue(importActionKey(this.pubkey, this.relayUrl))) { + return this.getCachedStore(); + } + this.migrationStarted = false; + } + if (this.pubkey && !this.destroyed) { + const legacy = await legacyFetch(); + if (legacy) { + this.migrationStarted = true; + await submitImport( + this.pubkey, + this.relayUrl, + legacy.event, + legacy.plaintext, + legacy.store, + ); + } + } + } catch { + this.workspaceProbeBlocked = true; + } + return this.cached ? this.getCachedStore() : null; + } + async subscribe( + onStore: (store: ChannelSectionStore) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_SECTION_WORKSPACE_PROJECTION], + "#d": [this.pubkey], + "#p": [this.pubkey], + limit: 0, + }, + (event) => { + if (this.destroyed) return; + void (async () => { + try { + let remote = projectionFromEvent(event, this.pubkey); + let action = projectionRevisionAction( + this.projection?.revision ?? null, + remote.revision, + ); + if (action === "refetch") { + const fetched = await fetchProjection(this.pubkey); + if ( + !fetched || + fetched.revision <= (this.projection?.revision ?? -1) + ) + return; + remote = fetched; + action = "accept"; + } + if (action !== "accept") return; + const store = await this.acceptProjection(remote); + onStore(store); + } catch { + this.workspaceProbeBlocked = true; + } + })(); + }, + ); + } + destroy(): void { + this.destroyed = true; + } +} diff --git a/desktop/src/features/sidebar/lib/sectionWorkspaceKinds.ts b/desktop/src/features/sidebar/lib/sectionWorkspaceKinds.ts new file mode 100644 index 000000000..33499c0ce --- /dev/null +++ b/desktop/src/features/sidebar/lib/sectionWorkspaceKinds.ts @@ -0,0 +1,2 @@ +export const KIND_SECTION_WORKSPACE_IMPORT = 9050; +export const KIND_SECTION_WORKSPACE_PROJECTION = 30623; diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 5a544e82b..4ffe648a6 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -12,6 +12,8 @@ import { ChannelSectionSyncManager } from "./channelSectionsSync"; import type { RemoteSections } from "./channelSectionsSync"; import { swapSectionOrder } from "./channelSectionsHelpers"; +import { SectionWorkspaceSyncManager } from "./sectionWorkspace"; + export type { ChannelSection } from "./channelSectionsStorage"; import type { @@ -42,6 +44,9 @@ export function useChannelSections( }); const managerRef = React.useRef(null); + const workspaceManagerRef = React.useRef( + null, + ); const lastAppliedRemoteTs = React.useRef(0); const lastAppliedEventId = React.useRef(""); @@ -56,9 +61,15 @@ export function useChannelSections( lastAppliedRemoteTs.current = 0; lastAppliedEventId.current = ""; managerRef.current = new ChannelSectionSyncManager(pubkey, relayUrl); + workspaceManagerRef.current = new SectionWorkspaceSyncManager( + pubkey, + relayUrl, + ); return () => { managerRef.current?.destroy(); managerRef.current = null; + workspaceManagerRef.current?.destroy(); + workspaceManagerRef.current = null; }; }, [pubkey, relayUrl]); @@ -105,20 +116,57 @@ export function useChannelSections( React.useEffect(() => { if (!pubkey || !relayUrl) return; let cancelled = false; - const local = readChannelSectionsStore(pubkey, relayUrl); - void managerRef.current?.bootstrap(local).then((result) => { + void (async () => { + const workspaceManager = workspaceManagerRef.current; + const legacyManager = managerRef.current; + if (!workspaceManager || !legacyManager) return; + const workspaceStore = await workspaceManager.bootstrap(async () => { + const legacy = await legacyManager.fetchLegacySource(); + return legacy; + }); + if (cancelled) return; + if (workspaceStore) setStore(workspaceStore); + if (workspaceManager.isCanonical()) { + legacyManager.cancelPendingPublish(); + return; + } + const result = await legacyManager.bootstrap( + readChannelSectionsStore(pubkey, relayUrl), + ); if (cancelled) return; if (result.action === "apply-remote") { setStore(applyRemote(result.data)); } - // "hold": seed already performed by bootstrap (if first-sync), or - // blocked (failed fetch / prior watermark). Hook does nothing. - }); + })(); return () => { cancelled = true; }; }, [pubkey, relayUrl, applyRemote]); + React.useEffect(() => { + if (!pubkey || !relayUrl) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void workspaceManagerRef.current + ?.subscribe((workspaceStore) => { + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + } + if (!cancelled) setStore(workspaceStore); + }) + .then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey, relayUrl]); + React.useEffect(() => { if (!pubkey) return; let unsub: (() => Promise) | null = null; @@ -187,6 +235,10 @@ export function useChannelSections( sections: [...current.sections, section], }); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current; + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -217,6 +269,10 @@ export function useChannelSections( if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -244,6 +300,10 @@ export function useChannelSections( if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -258,6 +318,10 @@ export function useChannelSections( const next = swapSectionOrder(prev, sectionId, "up"); if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -272,6 +336,10 @@ export function useChannelSections( const next = swapSectionOrder(prev, sectionId, "down"); if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -289,6 +357,10 @@ export function useChannelSections( }); const next: ChannelSectionStore = { ...prev, sections }; if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -312,6 +384,10 @@ export function useChannelSections( if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); @@ -331,6 +407,10 @@ export function useChannelSections( if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } + if (workspaceManagerRef.current?.isCanonical()) { + managerRef.current?.cancelPendingPublish(); + return next; + } managerRef.current?.publishSections(next); return next; }); diff --git a/desktop/src/shared/api/workspaceCrypto.ts b/desktop/src/shared/api/workspaceCrypto.ts new file mode 100644 index 000000000..b60cb828d --- /dev/null +++ b/desktop/src/shared/api/workspaceCrypto.ts @@ -0,0 +1,21 @@ +import { invokeTauri } from "./tauri"; + +export async function generateWorkspaceKey(): Promise { + return invokeTauri("generate_workspace_key"); +} + +export async function encryptWorkspaceMetadata(input: { + keyHex: string; + plaintext: string; + aad: string; +}): Promise { + return invokeTauri("encrypt_workspace_metadata", input); +} + +export async function decryptWorkspaceMetadata(input: { + keyHex: string; + envelope: string; + aad: string; +}): Promise { + return invokeTauri("decrypt_workspace_metadata", input); +}