mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add section workspace migration
Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
Generated
+1
@@ -1083,6 +1083,7 @@ dependencies = [
|
||||
name = "buzz-desktop"
|
||||
version = "0.5.11"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"atomic-write-file",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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:<nonce>:<ciphertext>`
|
||||
//! 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<Aes256Gcm, String> {
|
||||
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<String, String> {
|
||||
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<u8>), String> {
|
||||
let parts = envelope.split(':').collect::<Vec<_>>();
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
generate_key_hex()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn encrypt_workspace_metadata(
|
||||
key_hex: String,
|
||||
plaintext: String,
|
||||
aad: String,
|
||||
) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,12 @@ export type RemoteSections = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
export type LegacySectionSource = {
|
||||
event: RelayEvent;
|
||||
plaintext: string;
|
||||
store: ChannelSectionStore;
|
||||
};
|
||||
|
||||
async function decryptAndParse(
|
||||
event: RelayEvent,
|
||||
): Promise<RemoteSections | null> {
|
||||
@@ -58,6 +64,26 @@ export class ChannelSectionSyncManager {
|
||||
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
|
||||
}
|
||||
|
||||
async fetchLegacySource(): Promise<LegacySectionSource | null> {
|
||||
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<FetchResult<RemoteSections>> {
|
||||
try {
|
||||
const events = await relayClient.fetchEvents({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown>,
|
||||
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<string> {
|
||||
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<string> {
|
||||
return decryptWorkspaceMetadata({
|
||||
keyHex: key,
|
||||
envelope: value,
|
||||
aad: aad(community, owner, sectionId, epoch, purpose),
|
||||
});
|
||||
}
|
||||
async function projectionStore(
|
||||
projection: SectionWorkspaceProjection,
|
||||
key: string,
|
||||
community: string,
|
||||
): Promise<ChannelSectionStore> {
|
||||
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<SectionWorkspaceProjection | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
const sectionIdByLegacyId = new Map<string, string>();
|
||||
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<ChannelSectionStore> {
|
||||
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<LegacySectionSource | null>,
|
||||
): Promise<ChannelSectionStore | null> {
|
||||
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<void>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const KIND_SECTION_WORKSPACE_IMPORT = 9050;
|
||||
export const KIND_SECTION_WORKSPACE_PROJECTION = 30623;
|
||||
@@ -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<ChannelSectionSyncManager | null>(null);
|
||||
const workspaceManagerRef = React.useRef<SectionWorkspaceSyncManager | null>(
|
||||
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<void>) | 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<void>) | 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;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { invokeTauri } from "./tauri";
|
||||
|
||||
export async function generateWorkspaceKey(): Promise<string> {
|
||||
return invokeTauri<string>("generate_workspace_key");
|
||||
}
|
||||
|
||||
export async function encryptWorkspaceMetadata(input: {
|
||||
keyHex: string;
|
||||
plaintext: string;
|
||||
aad: string;
|
||||
}): Promise<string> {
|
||||
return invokeTauri<string>("encrypt_workspace_metadata", input);
|
||||
}
|
||||
|
||||
export async function decryptWorkspaceMetadata(input: {
|
||||
keyHex: string;
|
||||
envelope: string;
|
||||
aad: string;
|
||||
}): Promise<string> {
|
||||
return invokeTauri<string>("decrypt_workspace_metadata", input);
|
||||
}
|
||||
Reference in New Issue
Block a user